diff --git a/Build-ScrollSettings.ps1 b/Build-ScrollSettings.ps1 new file mode 100644 index 00000000..c7c9bd5e --- /dev/null +++ b/Build-ScrollSettings.ps1 @@ -0,0 +1,56 @@ +param([switch]$StageOnly, [string]$DependencyTargetDirectory = (Join-Path $PSScriptRoot 'target')) +$ErrorActionPreference = 'Stop' +$privateDirectory = Join-Path $PSScriptRoot '.local/scroll-settings' +$buildDirectory = Join-Path $privateDirectory 'build' +$artifact = Join-Path $buildDirectory 'skate3rust-scroll-settings.exe' +$destination = Join-Path $privateDirectory 'skate3-scroll-settings.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=-scroll-settings ` + -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', 'Scroll wheel: more settings', 'Native trainer controls are already owned by another mod')) { + if (-not $text.Contains($required)) { throw "Wrong scroll-settings artifact: missing $required" } + } + Copy-Item -LiteralPath $artifact -Destination $destination -Force + # Only original SDK example sources are staged. Never copy extracted assets. + $modDestination = Join-Path $privateDirectory 'mods/native-trainer' + New-Item -ItemType Directory -Force -Path $modDestination | Out-Null + foreach ($name in @('mod.json','main.lua','help.txt','README.md')) { + $source = Join-Path $PSScriptRoot "mods/native-trainer/$name" + $target = Join-Path $modDestination $name + if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target } + elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) { + Write-Warning "Preserving existing mod file: $target. Copy the updated source there when ready." + } + } + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'mods/README.md') -Destination (Join-Path $privateDirectory 'mods/README.md') -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-SCROLL-SETTINGS.bat b/PLAY-SCROLL-SETTINGS.bat new file mode 100644 index 00000000..53b65d0b --- /dev/null +++ b/PLAY-SCROLL-SETTINGS.bat @@ -0,0 +1,23 @@ +@echo off +setlocal +cd /d "%~dp0" +if not defined SKATE3_ASSETS ( + if exist "%~dp0.local\scroll-settings\assets.path" set /p "SKATE3_ASSETS="<"%~dp0.local\scroll-settings\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\scroll-settings\skate3-scroll-settings.exe" ( + echo Build missing. Run Build-ScrollSettings.ps1 first. + pause + exit /b 1 +) +set "SKATE3_MOD_SETTINGS=%~dp0.local\scroll-settings\settings" +if exist "%~dp0.local\scroll-settings\overlay.path" set /p "SKATE3_SESSION_MARKER_OVERLAY="<"%~dp0.local\scroll-settings\overlay.path" +echo Open Mods and ENABLE Native Trainer. Escape shows draggable settings windows. Drag their title bars; scroll the mouse wheel for more settings. +echo Mods folder: %~dp0.local\scroll-settings\mods +echo Trainer log: %~dp0.local\scroll-settings\session.log +"%~dp0.local\scroll-settings\skate3-scroll-settings.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\scroll-settings\session.log" 2>&1 +if errorlevel 1 pause diff --git a/crates/skate-game/src/modding/panel.rs b/crates/skate-game/src/modding/panel.rs index 69146ee3..1666fb75 100644 --- a/crates/skate-game/src/modding/panel.rs +++ b/crates/skate-game/src/modding/panel.rs @@ -1,14 +1,16 @@ //! Independent, draggable enabled-mod windows. Positions survive closing Escape. use super::{ModMenu, Mods}; +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use std::collections::BTreeMap; -const PAGE: usize = 7; + #[derive(Default)] struct Layout { position: Vec2, collapsed: bool, selected: usize, status: String, + reveal_selected: bool, } #[derive(Resource, Default)] pub(crate) struct EnabledPanel { @@ -28,6 +30,8 @@ struct Root(String); #[derive(Component)] struct Body(String); #[derive(Component)] +struct Viewport(String); +#[derive(Component)] struct Header(String); #[derive(Component)] struct Row(String, usize); @@ -46,7 +50,6 @@ enum Operation { Adjust(usize, i32), Collapse, Configure, - Page(i32), } pub(super) fn install(app: &mut App) { app.init_resource::() @@ -56,7 +59,7 @@ pub(super) fn install(app: &mut App) { .after(crate::graphics_menu::MenuInput) .before(crate::map_transition::MapTransitionSet), ) - .add_systems(Update, (sync, draw).chain()); + .add_systems(Update, (sync, draw, scroll).chain()); } fn button(parent: &mut ChildSpawnerCommands, text: &str, action: Action) { parent @@ -117,7 +120,7 @@ fn sync( panel.active = signature.first().map(|(id, _)| id.clone()); panel.focused = false; } - for (index, (id, _)) in signature.iter().enumerate() { + for (index, (id, settings)) 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() @@ -202,56 +205,69 @@ fn sync( }, )) .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), + body.spawn(( + Viewport(id.clone()), + ScrollPosition::default(), + Node { + height: px(280.), + max_height: Val::Vh(45.), + overflow: Overflow::scroll_y(), + flex_direction: FlexDirection::Column, + row_gap: px(6.), + ..default() + }, + )) + .with_children(|list| { + for i in 0..settings.len() { + list.spawn(( + Row(id.clone(), i), Node { - flex_grow: 1., - width: px(132.), + align_items: AlignItems::Center, + column_gap: px(4.), + min_height: px(34.), + flex_shrink: 0., ..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))); - }); - } + 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(( @@ -349,24 +365,20 @@ fn input( layout.selected = layout.selected.min(count - 1); if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { layout.selected = (layout.selected + count - 1) % count; + layout.reveal_selected = true; } if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { layout.selected = (layout.selected + 1) % count; + layout.reveal_selected = true; } if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { - action = Some(Action( - id.clone(), - Operation::Adjust(layout.selected % PAGE, -1), - )); + action = Some(Action(id.clone(), Operation::Adjust(layout.selected, -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), - )); + action = Some(Action(id.clone(), Operation::Adjust(layout.selected, 1))); } } } @@ -396,17 +408,11 @@ fn input( 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 selected = row; let Some((key, s)) = p.manifest.settings.iter().nth(selected) else { return; }; @@ -491,7 +497,7 @@ fn draw( } 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; + let index = row.1; node.display = if mods .manager .packages @@ -520,7 +526,7 @@ fn draw( 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{}", + "Drag title to move | +/- change values\nScroll wheel: more settings | Tab / X: focus\n{}", l.status ); } @@ -533,14 +539,7 @@ fn draw( .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)) - }); + .and_then(|p| p.manifest.settings.iter().nth(row).map(|(k, s)| (p, k, s))); **t = setting .map(|(p, key, s)| { if !is_value { @@ -558,3 +557,75 @@ fn draw( } } } + +// Scroll only the uppermost mod window under the pointer. Use computed UI +// geometry so DPI scaling, dragging and wrapped setting labels remain correct. +fn scroll( + mut wheel: MessageReader, + mut panel: ResMut, + pause: Res, + menu: Res, + custom: Res, + window: Single<&Window, With>, + roots: Query<(&Root, &ComputedNode, &UiGlobalTransform)>, + mut lists: Query<( + &Viewport, + &ComputedNode, + &UiGlobalTransform, + &mut ScrollPosition, + )>, + rows: Query<(&Row, &ComputedNode, &UiGlobalTransform)>, +) { + let delta: f32 = wheel + .read() + .map(|event| match event.unit { + MouseScrollUnit::Line => event.y * 40., + MouseScrollUnit::Pixel => event.y, + }) + .sum(); + if !pause.open || menu.open || custom.open || panel.drag.is_some() { + return; + } + let hovered = window.physical_cursor_position().and_then(|cursor| { + roots + .iter() + .filter(|(_, node, transform)| node.contains_point(**transform, cursor)) + .max_by_key(|(root, node, _)| { + (panel.active.as_ref() == Some(&root.0), node.stack_index) + }) + .map(|(root, _, _)| root.0.clone()) + }); + for (viewport, node, transform, mut position) in &mut lists { + let Some(layout) = panel.layouts.get_mut(&viewport.0) else { + continue; + }; + if layout.collapsed { + continue; + } + if hovered.as_ref() == Some(&viewport.0) && delta != 0. { + position.0.y -= delta; + layout.reveal_selected = false; + } + if layout.reveal_selected && node.size().y > 0. { + if let Some((_, row_node, row_transform)) = rows + .iter() + .find(|(row, _, _)| row.0 == viewport.0 && row.1 == layout.selected) + { + let center = transform + .inverse() + .transform_point2(row_transform.translation); + let top = center.y - row_node.size().y / 2. + node.size().y / 2.; + let bottom = top + row_node.size().y; + let shift = if top < 0. { + top + } else { + (bottom - node.size().y).max(0.) + }; + position.0.y += shift * node.inverse_scale_factor; + layout.reveal_selected = false; + } + } + let max = ((node.content_size().y - node.size().y) * node.inverse_scale_factor).max(0.); + position.0.y = position.0.y.clamp(0., max); + } +} diff --git a/docs/lua-modding.md b/docs/lua-modding.md index 8f776a45..b5d51f24 100644 --- a/docs/lua-modding.md +++ b/docs/lua-modding.md @@ -225,7 +225,7 @@ The trainer update passes 14 standalone Lua/schema tests and a static-CRT releas 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. +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 shows a continuous, clipped settings list. Use the mouse wheel over the window to scroll; keyboard/controller selection scrolls the selected setting into view. The value minus/plus buttons edit that row without changing the other rows. 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. ## Showcase 2.0 and top-level packages