Keep pause menu centered and add draggable per-mod settings windows

This commit is contained in:
chazmm committed 2026-09-08 18:53:10 +01:00
1 parent b809fc0701
commit 49caf4d40d
5 files changed
+541 -181

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
param([switch]$StageOnly, [string]$DependencyTargetDirectory = (Join-Path $PSScriptRoot 'target'))
$ErrorActionPreference = 'Stop'
$privateDirectory = Join-Path $PSScriptRoot '.local/mod-windows'
$buildDirectory = Join-Path $privateDirectory 'build'
$artifact = Join-Path $buildDirectory 'skate3rust-mod-windows.exe'
$destination = Join-Path $privateDirectory 'skate3-mod-windows.exe'
Push-Location $PSScriptRoot
$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS
try {
New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null
if (-not $StageOnly) {
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static'
# Dependencies may use a shared cache. The linker writes this task's
# artifact directly, never staging the cache's generic skate3rust.exe.
# Shared target fingerprints can refer to another worktree's local crates.
# Refresh only our local source timestamps; never clean the shared cache.
foreach ($crate in @('skate-core', 'skate-data', 'skate-mods')) {
(Get-Item -LiteralPath (Join-Path $PSScriptRoot "crates/$crate/src/lib.rs")).LastWriteTime = Get-Date
}
& cargo rustc --release --locked --target x86_64-pc-windows-msvc `
--target-dir $DependencyTargetDirectory -p skate-game --no-default-features `
--bin skate3rust -- -C extra-filename=-mod-windows `
-o (Join-Path $buildDirectory 'skate3rust.exe')
if ($LASTEXITCODE -ne 0) { throw 'Lua SDK build failed.' }
}
$bytes = [System.IO.File]::ReadAllBytes($artifact)
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
foreach ($required in @('--start-paused', 'Drag title to move', 'Native trainer controls are already owned by another mod')) {
if (-not $text.Contains($required)) { throw "Wrong mod-windows artifact: missing $required" }
}
Copy-Item -LiteralPath $artifact -Destination $destination -Force
$hash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
if ($hash -ne (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash) {
throw 'Staged executable hash mismatch.'
}
@{ executable = $destination; sha256 = $hash; source = $PSScriptRoot;
revision = (& git rev-parse HEAD); staticChecks = 'CLI, Mods UI, Lua quota diagnostics' } |
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $privateDirectory 'build-manifest.json') -Encoding UTF8
Write-Host "Ready for manual launch: $destination"
Write-Host "SHA256: $hash"
} finally {
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = $previousFlags
Pop-Location
}
+23
View File
@@ -0,0 +1,23 @@
@echo off
setlocal
cd /d "%~dp0"
if not defined SKATE3_ASSETS (
if exist "%~dp0.local\mod-windows\assets.path" set /p "SKATE3_ASSETS="<"%~dp0.local\mod-windows\assets.path"
)
if not defined SKATE3_ASSETS (
echo Set SKATE3_ASSETS to your prepared assets directory.
pause
exit /b 1
)
if not exist "%~dp0.local\mod-windows\skate3-mod-windows.exe" (
echo Build missing. Run Build-ModWindows.ps1 first.
pause
exit /b 1
)
set "SKATE3_MODS=%~dp0mods"
set "SKATE3_MOD_SETTINGS=%~dp0.local\mod-windows\settings"
if exist "%~dp0.local\mod-windows\overlay.path" set /p "SKATE3_SESSION_MARKER_OVERLAY="<"%~dp0.local\mod-windows\overlay.path"
echo Open Mods and ENABLE Native Trainer. Escape shows draggable settings windows. Drag their title bars.
echo Trainer log: %~dp0.local\mod-windows\session.log
"%~dp0.local\mod-windows\skate3-mod-windows.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\mod-windows\session.log" 2>&1
if errorlevel 1 pause
+3 -5
View File
@@ -270,9 +270,9 @@ pub(crate) fn interact(
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open;
}
if panel.focused && menu.open { return; }
let mut action = None;
if menu.open {
if !panel.focused {
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + 11) % 12;
}
@@ -285,7 +285,9 @@ pub(crate) fn interact(
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 {
action = Some((menu.selected, 1));
}
}
for (interaction, row) in &buttons {
if panel.dragging() { continue; }
if *interaction == Interaction::Pressed {
menu.selected = row.0;
action = Some((row.0, 1));
@@ -409,16 +411,12 @@ fn labels(
time: Res<Time<Real>>,
customiser: Res<crate::customiser::Customiser>,
mods: Res<crate::modding::ModMenu>,
enabled_mods: Res<crate::modding::Mods>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
) {
let has_enabled=enabled_mods.manager.packages.values().any(|p|p.running());
root.justify_content=if has_enabled {JustifyContent::FlexEnd}else{JustifyContent::Center};
root.padding.right=if has_enabled {percent(3)}else{px(0.)};
root.display = if menu.open && !customiser.open && !mods.open {
Display::Flex
} else {
+465 -176
View File
@@ -1,133 +1,271 @@
//! Enabled mod settings beside the normal pause menu, with independent focus.
//! Independent, draggable enabled-mod windows. Positions survive closing Escape.
use super::{ModMenu, Mods};
use bevy::prelude::*;
#[derive(Resource, Default)]
pub(crate) struct EnabledPanel {
pub focused: bool,
use std::collections::BTreeMap;
const PAGE: usize = 7;
#[derive(Default)]
struct Layout {
position: Vec2,
collapsed: bool,
selected: usize,
status: String,
}
#[derive(Component)]
struct Root;
#[derive(Component)]
struct Row(usize);
#[derive(Component)]
struct Label(usize);
#[derive(Component)]
struct Hint;
#[derive(Clone)]
enum Entry {
Mod(String),
Setting(String, String),
#[derive(Resource, Default)]
pub(crate) struct EnabledPanel {
pub focused: bool,
active: Option<String>,
layouts: BTreeMap<String, Layout>,
drag: Option<(String, Vec2)>,
signature: Vec<(String, Vec<String>)>,
}
fn entries(mods: &Mods) -> Vec<(String, Entry)> {
let mut rows = vec![];
for (id, p) in &mods.manager.packages {
if p.running() {
rows.push((
format!("{} - ENABLED", p.manifest.name),
Entry::Mod(id.clone()),
));
for (key, s) in &p.manifest.settings {
let value = &p.settings[key];
let display = value
.as_f64()
.map(|v| format!("{v:.2}"))
.unwrap_or_else(|| {
value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string())
});
rows.push((
format!("{} {}", s.label, display),
Entry::Setting(id.clone(), key.clone()),
));
}
}
impl EnabledPanel {
pub fn dragging(&self) -> bool {
self.drag.is_some()
}
rows
}
#[derive(Component)]
struct Root(String);
#[derive(Component)]
struct Body(String);
#[derive(Component)]
struct Header(String);
#[derive(Component)]
struct Row(String, usize);
#[derive(Component)]
struct Label(String, usize);
#[derive(Component)]
struct ValueLabel(String, usize);
#[derive(Component)]
struct Hint(String);
#[derive(Component)]
struct CollapseLabel(String);
#[derive(Component, Clone)]
struct Action(String, Operation);
#[derive(Clone)]
enum Operation {
Adjust(usize, i32),
Collapse,
Configure,
Page(i32),
}
pub(super) fn install(app: &mut App) {
app.init_resource::<EnabledPanel>()
.add_systems(PostStartup, setup)
.add_systems(
PreUpdate,
input
.after(crate::graphics_menu::MenuInput)
.before(crate::map_transition::MapTransitionSet),
)
.add_systems(Update, draw);
.add_systems(Update, (sync, draw).chain());
}
fn setup(mut commands: Commands) {
commands
fn button(parent: &mut ChildSpawnerCommands, text: &str, action: Action) {
parent
.spawn((
Root,
GlobalZIndex(12),
Button,
action,
Node {
display: Display::None,
position_type: PositionType::Absolute,
left: percent(3),
top: percent(8),
width: percent(39),
padding: UiRect::all(px(16)),
row_gap: px(7),
flex_direction: FlexDirection::Column,
border_radius: BorderRadius::all(px(12)),
min_width: px(28.),
height: px(28.),
padding: UiRect::horizontal(px(5.)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(Color::srgb(0.035, 0.055, 0.08)),
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
))
.with_children(|panel| {
panel.spawn((
Text::new("ENABLED MOD SETTINGS"),
.with_children(|p| {
p.spawn((
Text::new(text),
TextFont {
font_size: 23.,
font_size: 16.,
..default()
},
TextColor(Color::srgb(0.25, 1., 0.4)),
));
for i in 0..10 {
panel
.spawn((
Row(i),
Button,
Node {
padding: UiRect::all(px(7)),
min_height: px(32),
..default()
},
BackgroundColor(Color::srgb(0.08, 0.11, 0.15)),
))
.with_children(|row| {
row.spawn((
Label(i),
Text::new(""),
TextFont {
font_size: 16.,
..default()
},
TextColor(Color::WHITE),
));
});
}
panel.spawn((
Hint,
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::srgb(0.65, 0.85, 0.85)),
TextColor(Color::WHITE),
));
});
}
fn visible(
pause: &crate::graphics_menu::Menu,
menu: &ModMenu,
custom: &crate::customiser::Customiser,
) -> bool {
pause.open && !menu.open && !custom.open
fn sync(
mut commands: Commands,
mods: Res<Mods>,
mut panel: ResMut<EnabledPanel>,
roots: Query<Entity, With<Root>>,
) {
let signature: Vec<_> = mods
.manager
.packages
.iter()
.filter(|(_, p)| p.running())
.map(|(id, p)| {
(
id.clone(),
p.manifest.settings.keys().cloned().collect::<Vec<_>>(),
)
})
.collect();
if signature == panel.signature {
return;
}
for root in &roots {
commands.entity(root).despawn();
}
panel.signature = signature.clone();
panel.drag = None;
if !signature
.iter()
.any(|(id, _)| panel.active.as_ref() == Some(id))
{
panel.active = signature.first().map(|(id, _)| id.clone());
panel.focused = false;
}
for (index, (id, _)) in signature.iter().enumerate() {
panel.layouts.entry(id.clone()).or_insert_with(|| Layout {
position: Vec2::new(16. + index as f32 * 22., 64. + index as f32 * 34.),
..default()
});
let name = &mods.manager.packages[id].manifest.name;
commands
.spawn((
Root(id.clone()),
GlobalZIndex(12),
Node {
display: Display::None,
position_type: PositionType::Absolute,
width: px(320.),
max_width: percent(95),
padding: UiRect::all(px(10.)),
row_gap: px(8.),
flex_direction: FlexDirection::Column,
border_radius: BorderRadius::all(px(9.)),
..default()
},
BackgroundColor(Color::srgb(0.035, 0.055, 0.08)),
))
.with_children(|window| {
window
.spawn(Node {
width: percent(100),
column_gap: px(5.),
..default()
})
.with_children(|bar| {
bar.spawn((
Button,
Header(id.clone()),
Node {
flex_grow: 1.,
min_width: px(0.),
padding: UiRect::all(px(5.)),
..default()
},
BackgroundColor(Color::srgb(0.08, 0.18, 0.20)),
))
.with_children(|title| {
title.spawn((
Text::new(name),
TextFont {
font_size: 17.,
..default()
},
TextColor(Color::srgb(0.25, 1., 0.4)),
));
});
bar.spawn((
Button,
Action(id.clone(), Operation::Collapse),
Node {
width: px(30.),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
))
.with_children(|b| {
b.spawn((
CollapseLabel(id.clone()),
Text::new("-"),
TextFont {
font_size: 18.,
..default()
},
TextColor(Color::WHITE),
));
});
});
window
.spawn((
Body(id.clone()),
Node {
flex_direction: FlexDirection::Column,
row_gap: px(6.),
..default()
},
))
.with_children(|body| {
for i in 0..PAGE {
body.spawn((
Row(id.clone(), i),
Node {
align_items: AlignItems::Center,
column_gap: px(4.),
min_height: px(34.),
..default()
},
BackgroundColor(Color::srgb(0.08, 0.11, 0.15)),
))
.with_children(|row| {
row.spawn((
Label(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
flex_grow: 1.,
width: px(132.),
..default()
},
));
button(row, "-", Action(id.clone(), Operation::Adjust(i, -1)));
row.spawn((
ValueLabel(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
width: px(51.),
..default()
},
));
button(row, "+", Action(id.clone(), Operation::Adjust(i, 1)));
});
}
body.spawn(Node {
column_gap: px(6.),
..default()
})
.with_children(|p| {
button(p, "<", Action(id.clone(), Operation::Page(-1)));
button(p, ">", Action(id.clone(), Operation::Page(1)));
button(p, "Manage mod", Action(id.clone(), Operation::Configure));
});
body.spawn((
Hint(id.clone()),
Text::new(""),
TextFont {
font_size: 12.,
..default()
},
TextColor(Color::srgb(0.65, 0.85, 0.85)),
));
});
});
}
}
fn input(
mut panel: ResMut<EnabledPanel>,
@@ -136,53 +274,145 @@ fn input(
custom: Res<crate::customiser::Customiser>,
mut mods: ResMut<Mods>,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
nav: Res<crate::customiser::Navigation>,
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
headers: Query<(&Interaction, &Header), Changed<Interaction>>,
buttons: Query<(&Interaction, &Action), Changed<Interaction>>,
) {
let rows = entries(&mods);
if !visible(&pause, &menu, &custom) || rows.is_empty() {
if !pause.open || menu.open || custom.open {
panel.focused = false;
panel.drag = None;
return;
}
let ids: Vec<_> = mods
.manager
.packages
.iter()
.filter(|(_, p)| p.running())
.map(|(id, _)| id.clone())
.collect();
if ids.is_empty() {
panel.focused = false;
panel.drag = None;
return;
}
if keys.just_pressed(KeyCode::Tab) || nav.pressed & 0x4000 != 0 {
panel.focused = !panel.focused;
let next = if panel.focused {
panel
.active
.as_ref()
.and_then(|id| ids.iter().position(|s| s == id))
.map(|i| i + 1)
.unwrap_or(0)
} else {
0
};
panel.focused = next < ids.len();
if panel.focused {
panel.active = Some(ids[next].clone());
}
}
panel.selected = panel.selected.min(rows.len() - 1);
let mut direction = 0;
if !mouse.pressed(MouseButton::Left) {
panel.drag = None;
}
for (interaction, header) in &headers {
if *interaction == Interaction::Pressed && mouse.pressed(MouseButton::Left) {
if let (Some(cursor), Some(layout)) =
(window.cursor_position(), panel.layouts.get(&header.0))
{
panel.drag = Some((header.0.clone(), cursor - layout.position));
panel.active = Some(header.0.clone());
panel.focused = true;
}
}
}
if let (Some((id, offset)), Some(cursor)) = (panel.drag.clone(), window.cursor_position()) {
if let Some(layout) = panel.layouts.get_mut(&id) {
layout.position = (cursor - offset).clamp(
Vec2::ZERO,
Vec2::new(
(window.width() - 320.).max(0.),
(window.height() - 48.).max(0.),
),
);
}
}
let mut action = None;
if panel.focused {
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
panel.selected = (panel.selected + rows.len() - 1) % rows.len();
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
panel.selected = (panel.selected + 1) % rows.len();
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
direction = -1;
}
if keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::Enter)
|| nav.pressed & (8 | 0x1000) != 0
{
direction = 1;
if let Some(id) = panel.active.clone() {
if let (Some(p), Some(layout)) =
(mods.manager.packages.get(&id), panel.layouts.get_mut(&id))
{
let count = p.manifest.settings.len();
if count > 0 {
layout.selected = layout.selected.min(count - 1);
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
layout.selected = (layout.selected + count - 1) % count;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
layout.selected = (layout.selected + 1) % count;
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some(Action(
id.clone(),
Operation::Adjust(layout.selected % PAGE, -1),
));
}
if keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::Enter)
|| nav.pressed & (8 | 0x1000) != 0
{
action = Some(Action(
id.clone(),
Operation::Adjust(layout.selected % PAGE, 1),
));
}
}
}
}
}
let offset = panel.selected / 10 * 10;
for (interaction, row) in &buttons {
if *interaction == Interaction::Pressed && offset + row.0 < rows.len() {
for (interaction, button) in &buttons {
if *interaction == Interaction::Pressed {
action = Some(button.clone());
panel.active = Some(button.0.clone());
panel.focused = true;
panel.selected = offset + row.0;
direction = 1;
}
}
if direction == 0 {
let Some(Action(id, operation)) = action else {
return;
};
let Some(p) = mods.manager.packages.get(&id) else {
return;
};
if !p.running() {
return;
}
match &rows[panel.selected].1 {
Entry::Mod(id) => menu.configure(id.clone()),
Entry::Setting(id, key) => {
let p = &mods.manager.packages[id];
let s = &p.manifest.settings[key];
let v = &p.settings[key];
let Some(layout) = panel.layouts.get_mut(&id) else {
return;
};
match operation {
Operation::Collapse => {
layout.collapsed = !layout.collapsed;
}
Operation::Configure => menu.configure(id),
Operation::Page(direction) => {
let pages = p.manifest.settings.len().max(1).div_ceil(PAGE);
layout.selected = ((layout.selected / PAGE) as i32 + direction).rem_euclid(pages as i32)
as usize
* PAGE;
}
Operation::Adjust(row, direction) => {
if layout.collapsed {
return;
}
let selected = layout.selected / PAGE * PAGE + row;
let Some((key, s)) = p.manifest.settings.iter().nth(selected) else {
return;
};
let key = key.clone();
layout.selected = selected;
let v = &p.settings[&key];
let next = match s.kind.as_str() {
"number" => Some(serde_json::json!(
(v.as_f64().unwrap() + direction as f64 * s.step.unwrap())
@@ -193,7 +423,7 @@ fn input(
let i = s
.choices
.iter()
.position(|s| Some(s.as_str()) == v.as_str())
.position(|c| Some(c.as_str()) == v.as_str())
.unwrap_or(0);
Some(serde_json::json!(
s.choices
@@ -206,7 +436,7 @@ fn input(
}
};
if let Some(v) = next {
panel.status = mods.manager.setting(id, key, v).err().unwrap_or_default();
layout.status = mods.manager.setting(&id, &key, v).err().unwrap_or_default();
}
}
}
@@ -217,55 +447,114 @@ fn draw(
menu: Res<ModMenu>,
custom: Res<crate::customiser::Customiser>,
mods: Res<Mods>,
mut root: Single<&mut Node, With<Root>>,
mut rows: Query<(&Row, &mut Node, &mut BackgroundColor), Without<Root>>,
mut labels: Query<(&Label, &mut Text), Without<Hint>>,
mut hint: Single<&mut Text, (With<Hint>, Without<Label>)>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
mut roots: Query<(&Root, &mut Node, &mut GlobalZIndex)>,
mut bodies: Query<(&Body, &mut Node), Without<Root>>,
mut rows: Query<(&Row, &mut Node, &mut BackgroundColor), (Without<Root>, Without<Body>)>,
mut text: Query<(
&mut Text,
Option<&Label>,
Option<&ValueLabel>,
Option<&Hint>,
Option<&CollapseLabel>,
)>,
) {
let list = entries(&mods);
root.display = if visible(&pause, &menu, &custom) && !list.is_empty() {
Display::Flex
} else {
Display::None
};
let offset = panel.selected / 10 * 10;
for (label, mut text) in &mut labels {
**text = list
.get(offset + label.0)
.map(|e| e.0.clone())
.unwrap_or_default();
let visible = pause.open && !menu.open && !custom.open;
for (root, mut node, mut z) in &mut roots {
if let Some(layout) = panel.layouts.get(&root.0) {
node.display = if visible
&& mods
.manager
.packages
.get(&root.0)
.is_some_and(|p| p.running())
{
Display::Flex
} else {
Display::None
};
node.left = px(layout.position.x.clamp(0., (window.width() - 320.).max(0.)));
node.top = px(layout.position.y.clamp(0., (window.height() - 48.).max(0.)));
z.0 = if panel.active.as_ref() == Some(&root.0) {
14
} else {
12
};
}
}
for (row, mut node, mut color) in &mut rows {
node.display = if offset + row.0 < list.len() {
for (body, mut node) in &mut bodies {
node.display = if panel.layouts.get(&body.0).is_some_and(|l| !l.collapsed) {
Display::Flex
} else {
Display::None
};
color.0 = if panel.focused && offset + row.0 == panel.selected {
}
for (row, mut node, mut color) in &mut rows {
let selected = panel.layouts.get(&row.0).map_or(0, |l| l.selected);
let index = selected / PAGE * PAGE + row.1;
node.display = if mods
.manager
.packages
.get(&row.0)
.is_some_and(|p| index < p.manifest.settings.len())
{
Display::Flex
} else {
Display::None
};
color.0 = if panel.focused && panel.active.as_ref() == Some(&row.0) && index == selected {
Color::srgb(0.10, 0.30, 0.34)
} else {
Color::srgb(0.08, 0.11, 0.15)
};
}
let description = list
.get(panel.selected)
.and_then(|(_, entry)| match entry {
Entry::Setting(id, key) => Some(
mods.manager.packages[id].manifest.settings[key]
.description
.as_str(),
),
_ => None,
})
.unwrap_or("Select a mod name for its enable/disable controls.");
***hint = format!(
"Tab / X: {}\nUp/Down select | Left/Right adjust | Click increases\n{}\n{}",
if panel.focused {
"return to pause controls"
} else {
"focus mod settings"
},
description,
panel.status
);
for (mut t, label, value, hint, collapse) in &mut text {
if let Some(c) = collapse {
**t = if panel.layouts.get(&c.0).is_some_and(|l| l.collapsed) {
"+"
} else {
"-"
}
.into();
}
if let Some(h) = hint {
if let Some(l) = panel.layouts.get(&h.0) {
**t = format!(
"Drag title to move | +/- change values\nTab / X cycles windows; arrows adjust\n{}",
l.status
);
}
}
let field = label
.map(|l| (&l.0, l.1, false))
.or_else(|| value.map(|v| (&v.0, v.1, true)));
if let Some((id, row, is_value)) = field {
let setting = mods
.manager
.packages
.get(id)
.zip(panel.layouts.get(id))
.and_then(|(p, l)| {
p.manifest
.settings
.iter()
.nth(l.selected / PAGE * PAGE + row)
.map(|(k, s)| (p, k, s))
});
**t = setting
.map(|(p, key, s)| {
if !is_value {
s.label.clone()
} else {
let v = &p.settings[key];
v.as_f64().map(|n| format!("{n:.2}")).unwrap_or_else(|| {
v.as_str()
.map(str::to_owned)
.unwrap_or_else(|| v.to_string())
})
}
})
.unwrap_or_default();
}
}
}
+6
View File
@@ -220,3 +220,9 @@ Source bindings: ground pop scales the current `GroundJumpMode` minimum/maximum
Trainer integration is compile-checked and the standalone Lua/schema suite covers command validation and setting delivery. Native gameplay effects, controller focus and two-panel layout still require the user-run launcher. No game was launched for this update.
The trainer update passes 14 standalone Lua/schema tests and a static-CRT release build. Use **PLAY-NATIVE-TRAINER.bat** for this update; its executable and preferences are isolated from the previous SDK test build.
## Draggable per-mod windows
Use `PLAY-MOD-WINDOWS.bat` for the latest settings-window build (`Build-ModWindows.ps1`). The ordinary Escape menu stays in its original centered position. Each enabled mod receives its own compact window, initially at the left, with a title you can drag. The title-bar minus button collapses that window; plus expands it. Each setting has separate minus/plus controls to decrease/increase numbers (within the manifest bounds), cycle choices, or toggle booleans. String controls open the full editor. Values are still saved and delivered to Lua immediately.
Positions and collapse state are retained for the current process, including closing Escape and disabling/re-enabling a mod; they are not saved across game restarts. Clicking a window brings it forward. Tab/controller X cycles focus through enabled mods and then back to the main pause controls; arrows select/adjust within the focused mod. Each window pages seven settings with its own previous/next buttons. Manage mod opens that mod's enable/disable page. The main pause controls remain mouse-clickable when a mod has keyboard focus. Dragging prevents accidental main-menu mouse actions. No game was launched to validate this UI update; its interactive behavior remains for user testing.