From 49caf4d40d2f74b85cd7eda56788176cf8c8ba5b Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 18:53:10 +0100 Subject: [PATCH] Keep pause menu centered and add draggable per-mod settings windows --- Build-ModWindows.ps1 | 44 ++ PLAY-MOD-WINDOWS.bat | 23 + crates/skate-game/src/graphics_menu.rs | 8 +- crates/skate-game/src/modding/panel.rs | 641 ++++++++++++++++++------- docs/lua-modding.md | 6 + 5 files changed, 541 insertions(+), 181 deletions(-) create mode 100644 Build-ModWindows.ps1 create mode 100644 PLAY-MOD-WINDOWS.bat diff --git a/Build-ModWindows.ps1 b/Build-ModWindows.ps1 new file mode 100644 index 00000000..0e861e88 --- /dev/null +++ b/Build-ModWindows.ps1 @@ -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 +} diff --git a/PLAY-MOD-WINDOWS.bat b/PLAY-MOD-WINDOWS.bat new file mode 100644 index 00000000..13d69370 --- /dev/null +++ b/PLAY-MOD-WINDOWS.bat @@ -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 diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 13f63503..2a35d5b0 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -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>, customiser: Res, mods: Res, - enabled_mods: Res, window: Single<&Window, With>, mut root: Single<&mut Node, With>, mut labels: Query<(&MenuLabel, &mut Text), Without>, mut status: Single<&mut Text, With>, 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 { diff --git a/crates/skate-game/src/modding/panel.rs b/crates/skate-game/src/modding/panel.rs index e139df7d..69146ee3 100644 --- a/crates/skate-game/src/modding/panel.rs +++ b/crates/skate-game/src/modding/panel.rs @@ -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, + layouts: BTreeMap, + drag: Option<(String, Vec2)>, + signature: Vec<(String, Vec)>, } -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::() - .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, + mut panel: ResMut, + roots: Query>, +) { + let signature: Vec<_> = mods + .manager + .packages + .iter() + .filter(|(_, p)| p.running()) + .map(|(id, p)| { + ( + id.clone(), + p.manifest.settings.keys().cloned().collect::>(), + ) + }) + .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, @@ -136,53 +274,145 @@ fn input( custom: Res, mut mods: ResMut, keys: Res>, + mouse: Res>, nav: Res, - buttons: Query<(&Interaction, &Row), Changed>, + window: Single<&Window, With>, + headers: Query<(&Interaction, &Header), Changed>, + buttons: Query<(&Interaction, &Action), Changed>, ) { - 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, custom: Res, mods: Res, - mut root: Single<&mut Node, With>, - mut rows: Query<(&Row, &mut Node, &mut BackgroundColor), Without>, - mut labels: Query<(&Label, &mut Text), Without>, - mut hint: Single<&mut Text, (With, Without