Skip to content

Commit 0b6896e

Browse files
authored
Launcher: move the options into a Settings window with tabs (#40)
The launcher's options move into a Settings window with tabs (Settings… button and Cmd-,): Graphics, Controls and More, plus Restore Original Game. The main window keeps the game folder, full screen, Saves, Logs, Report a Problem and Play.
1 parent 24bfb8d commit 0b6896e

7 files changed

Lines changed: 356 additions & 124 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -283,16 +283,19 @@ Do this after adding a `GA(...)` address or a `sub_` name to the runtime; game
283283
addresses written without `GA(...)` would be wrong in the North American engine.
284284

285285
The app opens a small launcher window first (`launcher/`, built by `pack.sh`
286-
with `swiftc`): it finds and checks `SPEED2.EXE`, offers Original or HD graphics
287-
and full screen, opens the logs folder, manages saves (**Saves…**: the career
288-
profiles, backups, ZIP export and import; the current saves are backed up before
289-
an import replaces them; tests/save_manager.sh), and **Make Copy…** builds a
290-
portable app with the game files inside (APFS clones, seconds on the same disk),
291-
so nobody needs `pack.sh --embed` or a terminal for that. Untick "Show this
292-
window when the app opens" to go straight into the game (hold Option to get the
293-
window back). `NFSU2_SKIP_LAUNCHER=1` / `NFSU2_ENGINE=original|hd` do the same
294-
from scripts. The Controller card turns the game controller on or off and sets its stick
295-
dead zone (`NFSU2_PAD` / `NFSU2_PAD_DEADZONE`, which still win when set by hand).
286+
with `swiftc`): it finds and checks `SPEED2.EXE`, offers full screen, opens the
287+
logs folder and manages saves (**Saves…**: the career profiles, backups, ZIP
288+
export and import; the current saves are backed up before an import replaces
289+
them; tests/save_manager.sh). **Settings…** (Cmd-,) holds the options on tabs:
290+
Original or HD graphics and the HUD on **Graphics**; on **Controls**, the game
291+
controller on or off and its stick dead zone (`NFSU2_PAD` / `NFSU2_PAD_DEADZONE`,
292+
which still win when set by hand); on **More**, **Make Copy…** builds a portable
293+
app with the game files inside (APFS clones, seconds on the same disk), so nobody
294+
needs `pack.sh --embed` or a terminal for that.
295+
**Restore Original Game** sets every option back to the original, unmodified
296+
game. Untick "Show this window when the app opens" to go straight into the game
297+
(hold Option to get the window back). `NFSU2_SKIP_LAUNCHER=1` / `NFSU2_ENGINE=original|hd` do the same
298+
from scripts.
296299

297300
![Launcher window](screenshots/selected/launcher.png)
298301

@@ -325,15 +328,15 @@ in the 3D menus. Method and raw numbers are in `WINE_COMPARISON.md`.
325328
**Is there a ready-made download?**
326329
Yes: the [Releases page](https://github.com/TalhaMudo/NFSU2Recomp/releases/latest) has `NFSU2.zip`. The app holds both
327330
graphics modes, Original and HD (MSAA, ray-traced reflections), chosen in its
328-
window; older releases had a separate `NFSU2.HD.zip`. It contains no game files:
331+
Settings; older releases had a separate `NFSU2.HD.zip`. It contains no game files:
329332
the window asks for the folder that holds your own `SPEED2.EXE`.
330333
The app is not notarized, so macOS blocks the first start: open System Settings ›
331334
Privacy & Security and click **Open Anyway** (on macOS 14 and older: right-click
332335
the app › Open). If macOS says the app is damaged, run once in Terminal:
333336
`xattr -dr com.apple.quarantine /Applications/NFSU2.app`
334337

335338
**Can I carry the game to another Mac in one piece?**
336-
Yes. In the app's window, **Make Copy…** creates "NFSU2 Portable.app" with the
339+
Yes. In the app's Settings (More tab), **Make Copy…** creates "NFSU2 Portable.app" with the
337340
game files inside: about 1.4 GB, plus the movies (1.1 GB) and the language packs in `LANGPACKS` if the game folder has them. Untick those when saving it for a smaller copy. It runs on its own; saves stay in
338341
`~/Library/Application Support/NFSU2 Saves` on each Mac. It contains the game,
339342
so keep it for yourself.

‎launcher/Game.swift‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ enum Prefs {
7171
static let engine = "LauncherEngine" // "original" | "hd"
7272
static let showAtStartup = "LauncherShowAtStartup"
7373
static let wideHUD = "LauncherWideHUD" // HUDPOS=1 for the engine
74+
75+
/// Each game option with its value in the original, unmodified game: Settings ›
76+
/// Restore Original Game sets them all. A new option adds its line here. (The
77+
/// game folder, full screen and showAtStartup are not game options.)
78+
static let originalGame: [OriginalValue] = [
79+
OriginalValue(\.engine, .original),
80+
OriginalValue(\.wideHUD, false),
81+
]
82+
}
83+
84+
/// An option of LauncherModel and the value that gives the original game.
85+
struct OriginalValue {
86+
let restore: (LauncherModel) -> Void
87+
let isSet: (LauncherModel) -> Bool
88+
init<T: Equatable>(_ option: ReferenceWritableKeyPath<LauncherModel, T>, _ value: T) {
89+
restore = { $0[keyPath: option] = value }
90+
isSet = { $0[keyPath: option] == value }
91+
}
7492
}
7593

7694
enum Locator {

‎launcher/Model.swift‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,25 @@ final class LauncherModel: ObservableObject {
187187
catch { alert("Could not reset the settings", error.localizedDescription) }
188188
}
189189

190+
/// Every option as in the original game (Prefs.originalGame)?
191+
var isOriginalGame: Bool { Prefs.originalGame.allSatisfy { $0.isSet(self) } }
192+
193+
/// Settings › Restore Original Game. Each option saves itself as it is set.
194+
func restoreOriginalGame() {
195+
for o in Prefs.originalGame { o.restore(self) }
196+
}
197+
198+
func confirmRestoreOriginalGame() {
199+
let a = NSAlert()
200+
a.messageText = "Restore the original game?"
201+
a.informativeText = "Every option in Settings goes back to the value that gives the original, unmodified game. Full screen, the game folder, the game's own settings and career saves are not changed."
202+
a.addButton(withTitle: "Restore")
203+
a.addButton(withTitle: "Cancel")
204+
guard a.runModal() == .alertFirstButtonReturn else { return }
205+
restoreOriginalGame()
206+
notice = "Every option is back as in the original game."
207+
}
208+
190209
// MARK: portable copy
191210

192211
func makePortable() {

‎launcher/Settings.swift‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// The Settings window: the launcher's options, on tabs. The main window
2+
// (Views.swift) stays small and only has what a player needs before Play.
3+
//
4+
// Adding an option:
5+
// 1. Game.swift: a Prefs key, and the value that gives the original game in
6+
// Prefs.originalGame (Restore Original Game walks that list).
7+
// 2. Model.swift: a @Published property (read in init, saved in didSet) and its
8+
// setenv(..., 0) in play(), so a variable set by hand still wins.
9+
// 3. Here: a SettingRow in its tab's view, inside a Card:
10+
// SettingRow(title: "Frame-rate counter",
11+
// description: "Frames per second in a corner. Off: as in the original game.",
12+
// isOn: $model.fpsCounter)
13+
// Other controls go in the closure: SettingRow(title: ..., description: ...) { Picker(...) }.
14+
// A switch's description ends with "Off: as in the original game."; a picker
15+
// labels the choice that gives the original game "(original)".
16+
// A Card holds any views, so it can also end with its own buttons:
17+
// Card("Data mods") { SettingRow(...) ...; HStack { Spacer(); Button("Add File…") { ... } } }
18+
// A tab with no rows is hidden: list it in SettingsTab.shown and give it a view in
19+
// SettingsView.page when its first row lands.
20+
import AppKit
21+
import SwiftUI
22+
23+
enum SettingsTab: String, CaseIterable {
24+
case graphics, picture, controls, online, game, mods, more
25+
26+
/// The tabs that have rows, in this order.
27+
static let shown: [SettingsTab] = [.graphics, .controls, .more]
28+
29+
var title: String { rawValue.capitalized }
30+
var icon: String { // SF Symbols available on macOS 11
31+
switch self {
32+
case .graphics: return "display"
33+
case .picture: return "camera.filters"
34+
case .controls: return "gamecontroller"
35+
case .online: return "network"
36+
case .game: return "car"
37+
case .mods: return "puzzlepiece"
38+
case .more: return "ellipsis.circle"
39+
}
40+
}
41+
}
42+
43+
/// One option: title, dim description, the control on the right.
44+
struct SettingRow<Control: View>: View {
45+
let title: String
46+
let description: String
47+
let control: Control
48+
init(title: String, description: String = "", @ViewBuilder control: () -> Control) {
49+
self.title = title; self.description = description; self.control = control()
50+
}
51+
var body: some View {
52+
HStack(alignment: .center, spacing: 12) {
53+
VStack(alignment: .leading, spacing: 1) {
54+
Text(title).font(.system(size: 12.5)).foregroundColor(Theme.text)
55+
if !description.isEmpty {
56+
Text(description).font(.system(size: 11)).foregroundColor(Theme.dim)
57+
.fixedSize(horizontal: false, vertical: true)
58+
}
59+
}
60+
Spacer(minLength: 8)
61+
control
62+
}
63+
}
64+
}
65+
66+
struct SettingSwitch: View {
67+
@Binding var isOn: Bool
68+
var body: some View { Toggle("", isOn: $isOn).labelsHidden().toggleStyle(SwitchToggleStyle()) }
69+
}
70+
71+
extension SettingRow where Control == SettingSwitch {
72+
/// A switch.
73+
init(title: String, description: String = "", isOn: Binding<Bool>) {
74+
self.init(title: title, description: description) { SettingSwitch(isOn: isOn) }
75+
}
76+
}
77+
78+
// MARK: tabs
79+
80+
struct GraphicsSettings: View {
81+
@ObservedObject var model: LauncherModel
82+
var body: some View {
83+
VStack(alignment: .leading, spacing: 12) {
84+
if model.canChooseEngine {
85+
Card("Engine") {
86+
Picker("", selection: $model.engine) {
87+
Text("Original").tag(LauncherModel.Engine.original)
88+
Text("HD").tag(LauncherModel.Engine.hd)
89+
}
90+
.pickerStyle(SegmentedPickerStyle())
91+
.labelsHidden()
92+
.fixedSize()
93+
Text(model.engine == .hd
94+
? "Anti-aliasing and ray-traced reflections on top of the game's own look. The Mac works harder and gets warmer."
95+
: "The game exactly as it looked on PC. Uses the least power.")
96+
.font(.system(size: 11)).foregroundColor(Theme.dim)
97+
.fixedSize(horizontal: false, vertical: true)
98+
}
99+
}
100+
Card("HUD") {
101+
SettingRow(title: "HUD at the screen edges",
102+
description: "On wide screens the minimap moves to the left and the gauges to the right. Off: the HUD stays in the 4:3 middle.",
103+
isOn: $model.wideHUD)
104+
}
105+
}
106+
}
107+
}
108+
109+
struct ControlsSettings: View {
110+
@ObservedObject var model: LauncherModel
111+
var body: some View {
112+
VStack(alignment: .leading, spacing: 12) {
113+
Card("Controller") {
114+
SettingRow(title: "Use a game controller",
115+
description: "Xbox, PlayStation or MFi. Connect it before Play; buttons are set in the game's Controls menu.",
116+
isOn: $model.controller)
117+
SettingRow(title: "Stick dead zone",
118+
description: "How far the stick moves before the game reacts. Raise it for a stick that drifts.") {
119+
HStack(spacing: 8) {
120+
Slider(value: $model.stickDeadzone, in: 0...0.5, step: 0.01).frame(width: 140)
121+
Text("\(Int((model.stickDeadzone * 100).rounded())) %")
122+
.font(.system(size: 11.5).monospacedDigit()).foregroundColor(Theme.dim)
123+
.frame(width: 40, alignment: .trailing)
124+
}
125+
}
126+
.disabled(!model.controller).opacity(model.controller ? 1 : 0.5)
127+
}
128+
}
129+
}
130+
}
131+
132+
struct MoreSettings: View {
133+
@ObservedObject var model: LauncherModel
134+
var body: some View {
135+
VStack(alignment: .leading, spacing: 12) {
136+
Card {
137+
SettingRow(title: "Portable copy",
138+
description: "One app with the game inside, for another Mac or a USB drive. Movies and language packs can be left out to make it smaller.") {
139+
Button("Make Copy…") { model.makePortable() }
140+
.buttonStyle(SecondaryButtonStyle())
141+
.disabled(!model.isReady)
142+
}
143+
SettingRow(title: "Graphics settings in the game",
144+
description: "The game picks them for this Mac again on its next start, as on its first one. Career saves are not affected.") {
145+
Button("Reset…") { model.resetGraphics() }.buttonStyle(SecondaryButtonStyle())
146+
}
147+
}
148+
}
149+
}
150+
}
151+
152+
// MARK: window
153+
154+
/// The selected tab. An ObservableObject rather than @State: with the command line
155+
/// tools' Swift 6.4 compiler @State is a macro whose plugin isn't there (pack.sh).
156+
final class SettingsSelection: ObservableObject {
157+
@Published var tab: SettingsTab
158+
init(_ tab: SettingsTab) { self.tab = tab }
159+
}
160+
161+
struct SettingsView: View {
162+
static let size = CGSize(width: 560, height: 470)
163+
@ObservedObject var model: LauncherModel
164+
@ObservedObject private var selection: SettingsSelection
165+
private var tab: SettingsTab { selection.tab }
166+
167+
init(model: LauncherModel, tab: SettingsTab = SettingsTab.shown[0]) {
168+
self.model = model
169+
self.selection = SettingsSelection(tab)
170+
}
171+
172+
@ViewBuilder private var page: some View {
173+
switch tab {
174+
case .graphics: GraphicsSettings(model: model)
175+
case .controls: ControlsSettings(model: model)
176+
case .more: MoreSettings(model: model)
177+
case .picture, .online, .game, .mods: EmptyView() // no rows yet
178+
}
179+
}
180+
181+
private var tabBar: some View {
182+
HStack(spacing: 4) {
183+
ForEach(SettingsTab.shown, id: \.self) { t in
184+
Button(action: { selection.tab = t }) {
185+
VStack(spacing: 3) {
186+
Image(systemName: t.icon).font(.system(size: 17))
187+
.frame(height: 22)
188+
Text(t.title).font(.system(size: 11, weight: .medium))
189+
}
190+
.foregroundColor(t == tab ? Theme.accent : Theme.dim)
191+
.frame(minWidth: 66).padding(.vertical, 6).padding(.horizontal, 4)
192+
.background(RoundedRectangle(cornerRadius: 7)
193+
.fill(Color.white.opacity(t == tab ? 0.08 : 0)))
194+
.contentShape(Rectangle())
195+
}
196+
.buttonStyle(PlainButtonStyle())
197+
}
198+
}
199+
}
200+
201+
var body: some View {
202+
ZStack {
203+
Theme.background
204+
VStack(spacing: 0) {
205+
tabBar.padding(.top, 2).padding(.bottom, 10)
206+
Rectangle().fill(Theme.cardBorder).frame(height: 1)
207+
ScrollView {
208+
page.padding(.horizontal, 22).padding(.vertical, 14)
209+
}
210+
Rectangle().fill(Theme.cardBorder).frame(height: 1)
211+
HStack(alignment: .center) {
212+
if let n = model.notice {
213+
Text(n).font(.system(size: 11)).foregroundColor(Theme.accent)
214+
} else if model.isOriginalGame {
215+
Text("Every option is as in the original game.")
216+
.font(.system(size: 11)).foregroundColor(Theme.dim)
217+
}
218+
Spacer(minLength: 8)
219+
Button("Restore Original Game…") { model.confirmRestoreOriginalGame() }
220+
.buttonStyle(SecondaryButtonStyle())
221+
.disabled(model.isOriginalGame)
222+
.help("Every option back to the value that gives the original, unmodified game.")
223+
}
224+
.padding(.horizontal, 22).padding(.vertical, 12)
225+
}
226+
if let j = model.job { JobOverlay(model: model, job: j) }
227+
}
228+
.frame(width: SettingsView.size.width, height: SettingsView.size.height)
229+
}
230+
}
231+
232+
/// The Settings window: same look as the main window, a fixed size; a tab
233+
/// taller than the window scrolls.
234+
func makeSettingsWindow(model: LauncherModel, tab: SettingsTab) -> NSWindow {
235+
let w = NSWindow(contentRect: NSRect(origin: .zero, size: SettingsView.size),
236+
styleMask: [.titled, .closable, .miniaturizable],
237+
backing: .buffered, defer: false)
238+
w.titlebarAppearsTransparent = true
239+
w.title = "Settings"
240+
w.isMovableByWindowBackground = true
241+
w.isReleasedWhenClosed = false
242+
w.appearance = NSAppearance(named: .darkAqua)
243+
w.backgroundColor = NSColor(calibratedRed: 0.067, green: 0.071, blue: 0.082, alpha: 1)
244+
w.contentView = NSHostingView(rootView: SettingsView(model: model, tab: tab))
245+
w.center()
246+
return w
247+
}

0 commit comments

Comments
 (0)