Add a Main Menu + save browser, replacing auto-generate and the one-slot save

The app used to always generate a world straight from planet.cfg on launch
and F5 always overwrote a single fixed planet.save. Now it opens on a
full-screen Main Menu (Home / Settings / Load World) so every PlanetConfig
field, the seed, starting a new world, and browsing saves are all reachable
without hand-editing planet.cfg, and saves are timestamped so nothing is
silently overwritten. A toolbar button reopens the menu later without
disturbing a running world.

The settings editor is generic (no per-field UI code): configFieldTable()
reuses the existing CONFIG_FIELDS X-macro to build a runtime field table,
grouped into 25 categories via an explicit name->category lookup (a
"first field of each section" boundary scan was tried first and is wrong,
since CONFIG_FIELDS emits all doubles, then all ints, then the seed --
not struct declaration order, so most categories aren't contiguous in that
order). Save v25 adds the viewer's colour-mode/overlay state, kept out of
Planet::writeState/readState's signature so it touches none of the
existing headless tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
This commit is contained in:
Jonas Reith 2026-08-31 19:11:16 +02:00
parent 78e58171e5
commit e24326b735
14 changed files with 854 additions and 45 deletions

2
.gitignore vendored
View File

@ -3,7 +3,7 @@
# Runtime-generated, machine-specific # Runtime-generated, machine-specific
planet.cfg planet.cfg
planet.save *.save
screenshot*.png screenshot*.png
# Claude Code local (machine-specific) settings # Claude Code local (machine-specific) settings

View File

@ -72,7 +72,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
F fast-forward Phase-1 forming to settled (instant) F fast-forward Phase-1 forming to settled (instant)
R reseed planet (restart forming) R reseed planet (restart forming)
+ / - subdivision level (detail), 1..7 + / - subdivision level (detail), 1..7
F5 / F9 save / load full state (planet.save) F5 / F9 save / load full state (writes/reads a timestamped world_<time>.save)
F12 screenshot to screenshot.png F12 screenshot to screenshot.png
F11 export the 2D map as a high-resolution (~4K) PNG (also a toolbar button) F11 export the 2D map as a high-resolution (~4K) PNG (also a toolbar button)
Shift+F11 export a labeled atlas (~8K, every place/settlement name, no overlap; toolbar button) Shift+F11 export a labeled atlas (~8K, every place/settlement name, no overlap; toolbar button)
@ -80,28 +80,39 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
F1 toggle the clickable toolbar menu (top-right of the 3D globe; a button/ F1 toggle the clickable toolbar menu (top-right of the 3D globe; a button/
checkbox/dropdown for every key above, built with raygui) checkbox/dropdown for every key above, built with raygui)
CLI flags (applied before the first load/generate): The app opens on a full-screen **Main Menu** (not a live world): a Home tab (New
World / Back to World / seed + Randomize), a Settings tab editing every one of the
~240 PlanetConfig fields (grouped into 25 categories), and a Load World tab listing
every save. A toolbar "Main Menu" button (World/Time section) reopens it later
without disturbing a running world.
CLI flags (applied before the Main Menu is shown):
--seed N override the config seed --seed N override the config seed
--config PATH use an alternate config file instead of planet.cfg --config PATH use an alternate config file instead of planet.cfg
## Files (written in the working directory) ## Files (written in the working directory)
planet.cfg human-editable key=value config of every PlanetConfig parameter; planet.cfg human-editable key=value config of every PlanetConfig
auto-created on first run, reload live with F2. Range-checked on parameter; auto-created on first run, reload live with F2
load; an invalid file reverts to safe defaults (not overwritten). or edit any field in the Main Menu's Settings tab.
planet.save binary snapshot (versioned, currently v22: +diplomacy; v21 Range-checked on load/New World; invalid settings revert
to safe defaults (planet.cfg itself is not overwritten).
world_<time>.save one binary snapshot per save (versioned, currently v25:
+viewer colour-mode/overlay state; v24 +edit-mode locks;
v23 +cultural evolution; v22 +diplomacy; v21
+conflict/wars; v20 +settlements; v19 +ecoregions; v18 +conflict/wars; v20 +settlements; v19 +ecoregions; v18
+geography salt; v17 +geography/atlas; v16 +event log; v15 +geography salt; v17 +geography/atlas; v16 +event log; v15
+stateful volcanoes; v13 +Live World clock rate; v12 +stateful volcanoes; v13 +Live World clock rate; v12
+step-back history (~40 frames, so a load can rewind storms); +step-back history (~40 frames, so a load can rewind storms);
v11 +weather systems/storms; v10 +weather fields; v9 +moons; v11 +weather systems/storms; v10 +weather fields; v9 +moons;
v8 +Live World clock; v7 +biota): seed + config + full planet state; F5 v8 +Live World clock; v7 +biota): seed + config + full planet
writes it, F9 reloads and resumes deterministically. As of v6 state. F5/"Save" always writes a fresh timestamped file;
the config is stored as a self-describing key=value block (like F9/"Load" (or the Main Menu's Load World tab) picks/lists
planet.cfg), so adding/removing config fields no longer breaks saves them. As of v6 the config is stored as a self-describing
(unknown keys ignored, missing keys default). v6 cannot load pre-v6 key=value block (like planet.cfg), so adding/removing config
saves (one-time break) -- regenerate them. fields no longer breaks saves (unknown keys ignored, missing
keys default). v6 cannot load pre-v6 saves (one-time break).
## Config (planet.cfg, or PlanetConfig defaults in code) ## Config (planet.cfg, or PlanetConfig defaults in code)

View File

@ -501,6 +501,57 @@ on the Live World clock). **Steps 17 of the roadmap are done (plus a derived
Verified live: one continent now shows six distinct peoples instead of a single dominant culture, Verified live: one continent now shows six distinct peoples instead of a single dominant culture,
with realms still correctly mono-cultural. `src/sim` + `src/render`; cultures are derived/not saved, with realms still correctly mono-cultural. `src/sim` + `src/render`; cultures are derived/not saved,
so no save-format version bump. so no save-format version bump.
- **Main Menu + a proper save browser (was: always auto-generate, one fixed save slot)** *(done — see
`src/render/MainMenu.{hpp,cpp}`, `Viewer::startNewWorld()`, save v25)* — the app used to always
generate a world straight from `planet.cfg` on launch, and `F5` always overwrote the same
`planet.save`. Now it opens on a full-screen **Main Menu** (`AppScreen::MainMenu`, a complete early
return in `renderFrame()`/`handleInput()` — not a dim overlay like the Phase-3 modal, since the menu
must render before `planet.generate()` has ever run): a **Home** tab (New World / Back to World —
the latter only once a world exists — a prominent Seed field + Randomize, and status text), a
**Settings** tab editing **every one of the ~240 `PlanetConfig` fields**, and a **Load World** tab
listing every `*.save` file. Reachable again later via a toolbar "Main Menu" button (World/Time
section) without disturbing a running world — "Back to World" is a pure screen-flag flip. **Saves
are now timestamped**: `F5`/"Save" always writes a fresh `world_<timestamp>.save`
(`makeTimestampedSavePath()`, the same `strftime` idiom as the map/atlas export); `F9`/"Load" picks
the newest via `listSaveFiles()` (`LoadDirectoryFilesEx(".", ".save", false)`); the fixed
`SAVE_PATH` member is gone. **A generic settings editor drives all 240 fields with no per-field
code**: `Planet::configFieldTable()` (`src/sim/PlanetIO.cpp`) reuses the existing
`CONFIG_FIELDS(D, I, U)` X-macro a third time to build a runtime `vector<ConfigFieldDesc>` (name +
category + type-erased get/set lambdas) — the same "add a field, everything downstream keeps
working" property `writeConfigFields()`/`parseConfigStream()` already give `planet.cfg`/saves.
Fields are grouped into **25 categories** via an explicit `name → category` lookup table
(`kConfigFieldCategory`, hand-verified against `PlanetConfig`'s actual declaration order in
`PlanetTypes.hpp`) — **not** a "first field of each section" boundary scan over
`configFieldTable()`'s own iteration order, which was tried first and is a real trap: `CONFIG_FIELDS`
expands to all `D()` fields, then all `I()` fields, then `U(seed)`, so a category whose fields mix
types (nearly all of them — e.g. "Plate lifecycle" has 9 doubles early and 5 ints in the separate
int run) is **not contiguous** in that order; a boundary scan silently merged everything after the
first non-contiguous category into one bucket. `configCategoryList()` returns the 25 labels in
display order, derived from `kConfigFieldCategory`'s own (genuinely contiguous, hand-written) block
order. The Settings tab is a left category rail + a `GuiScrollPanel` field list (the exact
scroll/scissor pattern from `Toolbar.cpp`); each row is a generic `drawConfigFieldRow()` modeled on
Edit Mode's `editRow()` (`Panels.cpp`) minus the lock column, and `handleMainMenuInput()`'s
click-to-focus/type/Enter-commits/Escape-cancels loop mirrors Edit Mode's typed-numeric-entry
handling (`ViewerInput.cpp`) exactly — click a field, type a new value, Enter calls
`Viewer::commitMenuField()` (a `std::stod` in a `try/catch`, silently discarding on a bad number,
same as `commitEditField()`). No live bounds-checking while typing: `validateConfig()` only runs at
"New World" time (same as the existing `F2` behaviour), reverting to defaults on failure. **Save v25**
appends the viewer's **colour mode + all overlay toggles** (`showBorders`/`showDrift`/.../
`showRealms`, 13 bits) so a loaded world shows what it looked like when saved, not just its
simulation state — deliberately kept **out of** `Planet::writeState`/`readState`'s 15-`bool hasX`
signature (same idiom already used there for `settled`/`phase3`/`liveWorld`/`liveRate`) so this
purely-viewer-side addition touches zero of the ~18 `test_*.cpp` files that call `readState`
directly; instead it's written/read directly in `Viewer::saveGame()`/`loadGame()`, positioned after
the existing event-journal block (a live bug caught the position mismatch: the read was first
wired in right after the header fields while the write happens at the very end of the file, which
silently desynced every read after it and broke the *unrelated* `test_events` suite too — moving
the read to match the write's actual position fixed both). Camera angle, map zoom/pan, selected
cell and the Live-info tab stay session-local, matching the user's explicit scope call. New headless
`test_viewerstate.cpp` (modeled on `test_events.cpp`): the v25 round-trip, plus a version-patched
negative test confirming a pre-v25 save loads without crashing and leaves mode/overlays untouched.
`src/sim` (`Planet.hpp`, `PlanetIO.cpp`) + `src/render` (`Viewer.{hpp,cpp}`, `ViewerInput.cpp`,
`ViewerRender.cpp`, `Toolbar.cpp`, new `MainMenu.{hpp,cpp}`); `.gitignore`'s `planet.save` line
became `*.save`.
## Current state ## Current state
@ -923,6 +974,8 @@ src/
Picking.* mouse ray / sphere hit / nearest-cell / angle helpers Picking.* mouse ray / sphere hit / nearest-cell / angle helpers
Panels.* right-column UI: detail panel, hover info, world stats, edit-mode panel Panels.* right-column UI: detail panel, hover info, world stats, edit-mode panel
Toolbar.* clickable raygui sidebar menu (mouse-friendly counterpart to the key list) Toolbar.* clickable raygui sidebar menu (mouse-friendly counterpart to the key list)
MainMenu.* full-screen Main Menu: generic settings editor (all PlanetConfig fields), seed,
New World / Load World / Back to World (AppScreen::MainMenu vs. World)
RayGuiImpl.cpp the one RAYGUI_IMPLEMENTATION translation unit (raygui.h is declaration-only elsewhere) RayGuiImpl.cpp the one RAYGUI_IMPLEMENTATION translation unit (raygui.h is declaration-only elsewhere)
Viewer.{hpp,cpp} Viewer struct: all state + setup + sim orchestration Viewer.{hpp,cpp} Viewer struct: all state + setup + sim orchestration
ViewerInput.cpp handleInput(): camera, hover picking, click, keys ViewerInput.cpp handleInput(): camera, hover picking, click, keys
@ -1046,7 +1099,10 @@ on a settled world; re-press regenerates) · `W` enter/leave **Live World** (set
settlement — with per-field lock against automatic recompute; see below) · settlement — with per-field lock against automatic recompute; see below) ·
`F1` toggle the clickable **toolbar menu** (top-right of the 3D globe; every key above also has a `F1` toggle the clickable **toolbar menu** (top-right of the 3D globe; every key above also has a
button/checkbox/dropdown there — see below) · button/checkbox/dropdown there — see below) ·
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load · `+`/`-` subdivision level (1..7) · `F5` save (writes a fresh `world_<timestamp>.save`) · `F9` load
(the newest save) · a toolbar "Main Menu" button (World/Time section) opens the full-screen **Main
Menu** — edit any of the ~240 `planet.cfg` fields, set a seed, start a New World or browse/Load
World — without disturbing a running world (see below) ·
`F12` screenshot (`screenshot.png`) · `F11` export the 2D map as a high-resolution (~4K) `F12` screenshot (`screenshot.png`) · `F11` export the 2D map as a high-resolution (~4K)
`map_export_<timestamp>.png` (also a toolbar button) · `Shift+F11` export a labeled reference atlas `map_export_<timestamp>.png` (also a toolbar button) · `Shift+F11` export a labeled reference atlas
(~8K, every geography feature + settlement name, no overlapping text — also a toolbar button) · (~8K, every geography feature + settlement name, no overlapping text — also a toolbar button) ·
@ -1101,15 +1157,20 @@ Mode. Locked out (`GuiDisable`d) while the Phase-3 "start hydrology?" prompt is
everything else. everything else.
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate). file (both applied before the initial load/generate). The app opens on the **Main
Menu** rather than auto-generating a world; `planet.generate()` runs only once you
pick New World or Load World there (see the Main Menu entry above).
Two files in the working dir: `planet.cfg` (human-editable key=value of every `planet.cfg` (human-editable key=value of every PlanetConfig param, auto-created
PlanetConfig param, auto-created on first run, reload with `F2`) and on first run, reload with `F2` or edit any field live in the Main Menu's Settings
`planet.save` (binary: seed + config + full planet state, written/read by tab) plus **one `world_<timestamp>.save` file per save** in the working dir
`Planet::writeState`/`readState`, resumes deterministically). Config is (binary: seed + config + full planet state, written/read by
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe `Planet::writeState`/`readState`, resumes deterministically) — `F5`/the toolbar
defaults (without overwriting your `planet.cfg`) and shows a status message. The "Save" button always write a fresh timestamped file, `F9`/"Load" (or the Main
save header is versioned (currently **24**; v2 adds the `[`/`]` drift rate, v3 a Menu's Load World tab) picks/lists them via `listSaveFiles()`. Config is
range-checked by `validateConfig()` on load/`F2`/New World; an invalid file/settings
reverts to safe defaults (without overwriting your `planet.cfg`) and shows a status
message. The save header is versioned (currently **25**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population** key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
@ -1130,7 +1191,10 @@ counter (cultural evolution), likewise per-frame (per-settlement vector + the li
truncates, a replay re-creates), and v24 the **edit mode** block — a per-cell `editLock` bitmask truncates, a replay re-creates), and v24 the **edit mode** block — a per-cell `editLock` bitmask
(manual-edit locks, `F3`) as a trailing vector plus sparse locked-value maps for the climate/biota- (manual-edit locks, `F3`) as a trailing vector plus sparse locked-value maps for the climate/biota-
density/habitability fields (which have no other persistent storage — the map only holds an entry density/habitability fields (which have no other persistent storage — the map only holds an entry
while its lock bit is set); while its lock bit is set), and v25 the viewer's **colour mode + overlay toggles**
(kept out of `Planet::writeState`/`readState`'s signature — written/read directly in
`Viewer::saveGame()`/`loadGame()`, so it doesn't touch the ~18 `test_*.cpp` files that
call `readState` directly);
newer-than-supported is newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
@ -1143,7 +1207,9 @@ demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pr
no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin
again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs); again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs);
pre-v23 saves load with no culture state (re-seeded one-per-continent on the next refresh — the same pre-v23 saves load with no culture state (re-seeded one-per-continent on the next refresh — the same
peoples as before; evolution starts from there); pre-v24 saves load with no manual edits/locks. peoples as before; evolution starts from there); pre-v24 saves load with no manual edits/locks;
pre-v25 saves load with the colour mode/overlays left at whatever the Viewer already held (same
"session state survives a load" behaviour saves have always had for anything not in the file).
A load drops any **stale** pre-load `wxUndo` history and reloads the A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one. saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved

View File

@ -63,6 +63,7 @@ set(RENDER_SOURCES
src/render/Picking.cpp src/render/Picking.cpp
src/render/Panels.cpp src/render/Panels.cpp
src/render/Toolbar.cpp src/render/Toolbar.cpp
src/render/MainMenu.cpp
src/render/RayGuiImpl.cpp src/render/RayGuiImpl.cpp
src/render/Viewer.cpp src/render/Viewer.cpp
src/render/ViewerInput.cpp src/render/ViewerInput.cpp
@ -106,3 +107,11 @@ if(UNIX AND NOT APPLE)
target_link_libraries(test_events PRIVATE m pthread dl) target_link_libraries(test_events PRIVATE m pthread dl)
endif() endif()
add_test(NAME events COMMAND test_events) add_test(NAME events COMMAND test_events)
add_executable(test_viewerstate test_viewerstate.cpp ${RENDER_SOURCES})
target_include_directories(test_viewerstate PRIVATE src/sim src/render ${raygui_SOURCE_DIR}/src)
target_link_libraries(test_viewerstate PRIVATE planetsim_sim raylib)
if(UNIX AND NOT APPLE)
target_link_libraries(test_viewerstate PRIVATE m pthread dl)
endif()
add_test(NAME viewerstate COMMAND test_viewerstate)

275
src/render/MainMenu.cpp Normal file
View File

@ -0,0 +1,275 @@
#include "MainMenu.hpp"
#include "Viewer.hpp"
#include "raygui.h"
#include <cstring>
// The full-screen Main Menu (see MainMenu.hpp). Layout is a pure function of screenW/screenH (no
// world needs to exist), so geometry helpers here are shared verbatim between drawMainMenu() (which
// rebuilds the click-target rect lists in MainMenuState every frame -- same idiom as
// Panels.cpp's edit panel) and handleMainMenuInput() (which reads them back). Field editing mirrors
// Edit Mode's click-to-type interaction (Panels.cpp::editRow / ViewerInput.cpp's typing loop /
// Viewer::commitEditField) but is driven generically by configFieldTable() instead of a hand-written
// row per field.
namespace {
const float HEADER_H = 64.0f;
const float TAB_W = 160.0f, TAB_H = 32.0f, TAB_GAP = 8.0f, TAB_X0 = 260.0f;
struct TabDef { MenuTab tab; const char* label; };
const TabDef kTabs[] = {
{ MenuTab::Home, "Home" },
{ MenuTab::Settings, "Settings" },
{ MenuTab::LoadWorld, "Load World" },
};
Rectangle tabButtonRect(int i) {
return Rectangle{ TAB_X0 + i * (TAB_W + TAB_GAP), 16.0f, TAB_W, TAB_H };
}
Rectangle mainMenuBody(const Viewer& v) {
return Rectangle{ 0.0f, HEADER_H, (float)v.screenW, (float)v.screenH - HEADER_H };
}
// configFieldTable() index of the "seed" field (U(seed), the CONFIG_FIELDS token name),
// resolved once and shown prominently on the Home tab.
int seedFieldIndex() {
static const int idx = [] {
const auto& t = configFieldTable();
for (size_t i = 0; i < t.size(); ++i)
if (std::strcmp(t[i].name, "seed") == 0) return (int)i;
return -1;
}();
return idx;
}
// Draw one editable config-field row: a label + a value box (click to type, like Edit Mode's
// editRow() in Panels.cpp, minus the lock column -- config fields have no lock concept).
// Records the row's hit rect + table index into m.fieldRects/fieldIndices for the click handler.
int drawConfigFieldRow(MainMenuState& m, int fieldIdx, const ConfigFieldDesc& d, double value,
int x, int y, int w) {
Rectangle r{ (float)x, (float)y, (float)w, 22.0f };
bool active = (m.editingConfigField == fieldIdx);
DrawRectangleRec(r, active ? Color{40, 46, 64, 255} : Color{22, 24, 34, 255});
DrawRectangleLinesEx(r, 1, Color{70, 74, 90, 255});
DrawText(d.name, (int)r.x + 4, (int)r.y + 3, 14, Color{190, 195, 210, 255});
std::string val = active ? (m.editBuffer + "_")
: (d.isInteger ? TextFormat("%.0f", value) : TextFormat("%.4g", value));
int vw = MeasureText(val.c_str(), 14);
DrawText(val.c_str(), (int)(r.x + r.width) - vw - 6, (int)r.y + 3, 14,
active ? Color{255, 230, 140, 255} : RAYWHITE);
m.fieldRects.push_back(r);
m.fieldIndices.push_back(fieldIdx);
return y + 24;
}
// Fixed geometry for the Home tab's non-field buttons (New World / Back to World / Randomize)
// -- shared verbatim by drawHomeTab() and its click handler so they can never drift apart.
struct HomeLayout { Rectangle newBtn, backBtn, seedRow, randBtn; };
HomeLayout computeHomeLayout(Rectangle body) {
HomeLayout L;
float x = body.x + 40.0f, y = body.y + 90.0f;
L.newBtn = Rectangle{ x, y, 220.0f, 44.0f };
L.backBtn = Rectangle{ x + 220.0f + 16.0f, y, 220.0f, 44.0f };
y += 44.0f + 66.0f;
L.seedRow = Rectangle{ x, y, 280.0f, 30.0f };
L.randBtn = Rectangle{ x + 280.0f + 12.0f, y, 140.0f, 30.0f };
return L;
}
void drawHomeTab(Viewer& v, Rectangle body) {
MainMenuState& m = v.menuState;
HomeLayout L = computeHomeLayout(body);
int x = (int)body.x + 40;
DrawText("World Creation", x, (int)body.y + 40, 26, RAYWHITE);
DrawRectangleRec(L.newBtn, Color{70, 110, 70, 255});
DrawRectangleLinesEx(L.newBtn, 1, Color{110, 160, 110, 255});
DrawText("New World", (int)L.newBtn.x + 20, (int)L.newBtn.y + 12, 18, RAYWHITE);
bool canBack = m.worldEverGenerated;
DrawRectangleRec(L.backBtn, canBack ? Color{60, 70, 100, 255} : Color{34, 36, 44, 255});
DrawRectangleLinesEx(L.backBtn, 1, canBack ? Color{100, 120, 160, 255} : Color{60, 62, 70, 255});
DrawText("Back to World", (int)L.backBtn.x + 16, (int)L.backBtn.y + 12, 18,
canBack ? RAYWHITE : Color{110, 112, 120, 255});
DrawText("Seed", x, (int)L.seedRow.y - 22, 18, Color{190, 195, 210, 255});
int si = seedFieldIndex();
if (si >= 0) {
const auto& t = configFieldTable();
double val = t[si].get(v.cfg);
bool active = (m.editingConfigField == si);
DrawRectangleRec(L.seedRow, active ? Color{40, 46, 64, 255} : Color{22, 24, 34, 255});
DrawRectangleLinesEx(L.seedRow, 1, Color{70, 74, 90, 255});
std::string vs = active ? (m.editBuffer + "_") : TextFormat("%.0f", val);
DrawText(vs.c_str(), (int)L.seedRow.x + 8, (int)L.seedRow.y + 6, 18,
active ? Color{255, 230, 140, 255} : RAYWHITE);
m.fieldRects.push_back(L.seedRow);
m.fieldIndices.push_back(si);
}
DrawRectangleRec(L.randBtn, Color{50, 50, 70, 255});
DrawRectangleLinesEx(L.randBtn, 1, Color{90, 90, 120, 255});
DrawText("Randomize", (int)L.randBtn.x + 14, (int)L.randBtn.y + 6, 16, RAYWHITE);
int hy = (int)L.seedRow.y + 60;
DrawText("Tune every setting on the Settings tab, then start a new world here.",
x, hy, 16, Color{160, 164, 180, 255});
hy += 26;
DrawText("New World applies every change made here; unsaved edits to a running world",
x, hy, 16, Color{160, 164, 180, 255});
hy += 20;
DrawText("are kept if you just go Back to World instead.", x, hy, 16, Color{160, 164, 180, 255});
if (!v.statusMsg.empty() && GetTime() < v.statusUntil) {
hy += 30;
DrawText(v.statusMsg.c_str(), x, hy, 16, Color{120, 230, 140, 255});
}
}
void drawSettingsTab(Viewer& v, Rectangle body) {
MainMenuState& m = v.menuState;
const auto& cats = configCategoryList();
const auto& table = configFieldTable();
const float railW = 280.0f;
Rectangle rail{ body.x + 24.0f, body.y + 16.0f, railW, body.height - 32.0f };
DrawRectangleRec(rail, Color{16, 18, 26, 255});
DrawRectangleLinesEx(rail, 1, Color{70, 74, 90, 255});
float ry = rail.y + 4.0f;
for (size_t i = 0; i < cats.size(); ++i) {
Rectangle r{ rail.x + 4.0f, ry, rail.width - 8.0f, 26.0f };
bool active = (m.settingsCategory == (int)i);
DrawRectangleRec(r, active ? Color{50, 58, 84, 255} : Color{24, 26, 36, 255});
DrawText(cats[i], (int)r.x + 8, (int)r.y + 5, 14,
active ? Color{255, 230, 140, 255} : Color{190, 195, 210, 255});
m.categoryRects.push_back(r);
ry += 28.0f;
}
float px = rail.x + rail.width + 24.0f;
Rectangle scrollBounds{ px, body.y + 16.0f, (body.x + body.width) - px - 24.0f, body.height - 32.0f };
const char* cat = (m.settingsCategory >= 0 && m.settingsCategory < (int)cats.size())
? cats[m.settingsCategory] : "";
int count = 0;
for (const auto& d : table) if (std::strcmp(d.category, cat) == 0) ++count;
Rectangle content{ 0, 0, scrollBounds.width - 16.0f, (float)count * 26.0f + 16.0f };
Rectangle view{};
GuiScrollPanel(scrollBounds, nullptr, content, &m.settingsScroll, &view);
BeginScissorMode((int)view.x, (int)view.y, (int)view.width, (int)view.height);
float fx = scrollBounds.x + 8.0f + m.settingsScroll.x;
float fy = scrollBounds.y + 8.0f + m.settingsScroll.y;
float fw = content.width - 16.0f;
for (size_t i = 0; i < table.size(); ++i) {
if (std::strcmp(table[i].category, cat) != 0) continue;
double val = table[i].get(v.cfg);
fy = (float)drawConfigFieldRow(m, (int)i, table[i], val, (int)fx, (int)fy, (int)fw);
}
EndScissorMode();
}
void drawLoadWorldTab(Viewer& v, Rectangle body) {
MainMenuState& m = v.menuState;
int x = (int)body.x + 40, y = (int)body.y + 30;
DrawText("Load World", x, y, 22, RAYWHITE);
y += 40;
if (m.saveFiles.empty()) {
DrawText("No saves found.", x, y, 16, Color{160, 164, 180, 255});
return;
}
for (const auto& f : m.saveFiles) {
Rectangle r{ (float)x, (float)y, 560.0f, 28.0f };
DrawRectangleRec(r, Color{22, 24, 34, 255});
DrawRectangleLinesEx(r, 1, Color{70, 74, 90, 255});
DrawText(f.c_str(), (int)r.x + 8, (int)r.y + 5, 16, RAYWHITE);
m.saveFileRects.push_back(r);
y += 32;
}
}
}
void drawMainMenu(Viewer& v) {
MainMenuState& m = v.menuState;
m.categoryRects.clear();
m.fieldRects.clear(); m.fieldIndices.clear();
m.saveFileRects.clear();
DrawText("Planet Sim", 16, 14, 30, RAYWHITE);
for (size_t i = 0; i < std::size(kTabs); ++i) {
Rectangle r = tabButtonRect((int)i);
bool active = (m.tab == kTabs[i].tab);
DrawRectangleRec(r, active ? Color{60, 70, 100, 255} : Color{28, 30, 42, 255});
DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255});
int tw = MeasureText(kTabs[i].label, 18);
DrawText(kTabs[i].label, (int)(r.x + r.width / 2 - tw / 2), (int)(r.y + 7), 18,
active ? RAYWHITE : Color{180, 180, 195, 255});
}
Rectangle body = mainMenuBody(v);
switch (m.tab) {
case MenuTab::Home: drawHomeTab(v, body); break;
case MenuTab::Settings: drawSettingsTab(v, body); break;
case MenuTab::LoadWorld: drawLoadWorldTab(v, body); break;
}
}
void handleMainMenuInput(Viewer& v) {
MainMenuState& m = v.menuState;
// Typed numeric entry swallows other input while a field is focused (mirrors Edit Mode's
// ViewerInput.cpp typing loop / Viewer::commitEditField).
if (m.editingConfigField >= 0) {
int ch = GetCharPressed();
while (ch > 0) {
if ((ch >= '0' && ch <= '9') || ch == '.' || ch == '-') m.editBuffer += (char)ch;
ch = GetCharPressed();
}
if (IsKeyPressed(KEY_BACKSPACE) && !m.editBuffer.empty()) m.editBuffer.pop_back();
if (IsKeyPressed(KEY_ENTER)) v.commitMenuField();
if (IsKeyPressed(KEY_ESCAPE)) { m.editingConfigField = -1; m.editBuffer.clear(); }
return;
}
if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) return;
Vector2 mp = GetMousePosition();
for (size_t i = 0; i < std::size(kTabs); ++i) {
if (!CheckCollisionPointRec(mp, tabButtonRect((int)i))) continue;
m.tab = kTabs[i].tab;
m.editingConfigField = -1; m.editBuffer.clear();
if (m.tab == MenuTab::LoadWorld) m.saveFiles = listSaveFiles();
return;
}
Rectangle body = mainMenuBody(v);
if (m.tab == MenuTab::Home) {
HomeLayout L = computeHomeLayout(body);
if (CheckCollisionPointRec(mp, L.newBtn)) { v.startNewWorld(); return; }
if (m.worldEverGenerated && CheckCollisionPointRec(mp, L.backBtn)) { v.screen = AppScreen::World; return; }
if (CheckCollisionPointRec(mp, L.randBtn)) {
v.cfg.seed = (uint32_t)(GetTime() * 100000.0) | 1u; // odd, non-zero: matches the R-key reseed convention
return;
}
} else if (m.tab == MenuTab::Settings) {
for (size_t i = 0; i < m.categoryRects.size(); ++i) {
if (!CheckCollisionPointRec(mp, m.categoryRects[i])) continue;
m.settingsCategory = (int)i;
return;
}
} else { // LoadWorld
for (size_t i = 0; i < m.saveFileRects.size(); ++i) {
if (!CheckCollisionPointRec(mp, m.saveFileRects[i])) continue;
if (i < m.saveFiles.size()) v.loadGame(m.saveFiles[i].c_str());
return;
}
}
// Generic: click on any drawn config-field row (Home's seed row or a Settings-tab field)
// focuses it for typed entry, seeded with its current value.
for (size_t i = 0; i < m.fieldRects.size(); ++i) {
if (!CheckCollisionPointRec(mp, m.fieldRects[i])) continue;
int fieldIdx = m.fieldIndices[i];
const auto& d = configFieldTable()[fieldIdx];
m.editingConfigField = fieldIdx;
m.editBuffer = TextFormat(d.isInteger ? "%.0f" : "%.4g", d.get(v.cfg));
return;
}
}

35
src/render/MainMenu.hpp Normal file
View File

@ -0,0 +1,35 @@
#pragma once
#include "raylib.h"
#include <string>
#include <vector>
struct Viewer;
// The full-screen Main Menu: settings editor over every PlanetConfig field, a seed field,
// "New World" / "Load World" / "Back to World" actions. Shown at app startup (before any world
// has ever been generated) and reachable again later via a toolbar button, without disturbing a
// running world unless the user explicitly picks New or Load.
enum class AppScreen { MainMenu, World };
enum class MenuTab { Home, Settings, LoadWorld };
// Transient menu-UI state, owned by the Viewer, rebuilt each frame by drawMainMenu() (render
// side) and consumed by handleMainMenuInput() -- mirrors EditPanelState's pattern in Panels.hpp.
struct MainMenuState {
MenuTab tab = MenuTab::Home;
int settingsCategory = 0; // which left-rail category is open
int editingConfigField = -1; // index into configFieldTable(), -1 = nothing focused
std::string editBuffer;
bool worldEverGenerated = false; // gates the "Back to World" button
Vector2 settingsScroll{0, 0}; // GuiScrollPanel offset for the field list
std::vector<Rectangle> categoryRects; // left-rail hit rects
std::vector<Rectangle> fieldRects; // parallel to fieldIndices: the open category's fields
std::vector<int> fieldIndices; // parallel to fieldRects: index into configFieldTable()
std::vector<std::string> saveFiles; // Load World: paths, newest first
std::vector<Rectangle> saveFileRects; // parallel to saveFiles
};
// No separate style setup: raygui's re-skin (setupToolbarStyle(), called once in Viewer::init())
// is a global style context, already applied before either screen ever renders.
void drawMainMenu(Viewer& v); // draw the whole menu screen for this frame
void handleMainMenuInput(Viewer& v); // mouse/keyboard handling while screen == AppScreen::MainMenu

View File

@ -195,12 +195,19 @@ void drawToolbar(Viewer& v) {
if (GuiButton(takeRow(cx, cw, cy), "Reseed (R)")) v.reseed(); if (GuiButton(takeRow(cx, cw, cy), "Reseed (R)")) v.reseed();
{ {
Rectangle r = takeRow(cx, cw, cy); Rectangle r = takeRow(cx, cw, cy);
if (GuiButton(Rectangle{ r.x, r.y, halfW, r.height }, "Save (F5)")) v.saveGame(v.SAVE_PATH); if (GuiButton(Rectangle{ r.x, r.y, halfW, r.height }, "Save (F5)")) v.saveGame(makeTimestampedSavePath().c_str());
if (GuiButton(Rectangle{ r.x + halfW + GAP, r.y, halfW, r.height }, "Load (F9)")) v.loadGame(v.SAVE_PATH); if (GuiButton(Rectangle{ r.x + halfW + GAP, r.y, halfW, r.height }, "Load (F9)")) {
auto files = listSaveFiles();
if (!files.empty()) v.loadGame(files.front().c_str());
else v.setStatus("No saves found");
}
} }
// Deferred to after drawToolbar() returns -- see Viewer::pendingMapExport/pendingAtlasExport. // Deferred to after drawToolbar() returns -- see Viewer::pendingMapExport/pendingAtlasExport.
if (GuiButton(takeRow(cx, cw, cy), "Export Map (F11)")) v.pendingMapExport = true; if (GuiButton(takeRow(cx, cw, cy), "Export Map (F11)")) v.pendingMapExport = true;
if (GuiButton(takeRow(cx, cw, cy), "Export Atlas (Shift+F11)")) v.pendingAtlasExport = true; if (GuiButton(takeRow(cx, cw, cy), "Export Atlas (Shift+F11)")) v.pendingAtlasExport = true;
// Doesn't touch planet/cfg/mode/overlays -- "Back to World" on the Home tab is a pure no-op
// return, so the running world is completely undisturbed.
if (GuiButton(takeRow(cx, cw, cy), "Main Menu")) { v.screen = AppScreen::MainMenu; v.menuState.tab = MenuTab::Home; }
cy += SECTION_GAP; cy += SECTION_GAP;
// --- Civilization & Ecology (all need a settled world) ------------------ // --- Civilization & Ecology (all need a settled world) ------------------

View File

@ -5,6 +5,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <ctime>
#include <fstream> #include <fstream>
#include <set> #include <set>
#include <utility> #include <utility>
@ -129,18 +130,15 @@ bool Viewer::init(int argc, char** argv) {
if (cliSeed != 0) cfg.seed = cliSeed; // CLI --seed overrides config if (cliSeed != 0) cfg.seed = cliSeed; // CLI --seed overrides config
std::string cfgErr = validateConfig(cfg); std::string cfgErr = validateConfig(cfg);
if (!cfgErr.empty()) cfg = PlanetConfig{}; // revert to safe defaults if (!cfgErr.empty()) cfg = PlanetConfig{}; // revert to safe defaults
planet.generate(cfg); // No planet.generate() here anymore -- the app opens on the Main Menu; a world is only
// built once the user picks "New World" (startNewWorld()) or "Load World" (loadGame()).
cam.position = {0, 0, 6}; cam.target = {0, 0, 0}; cam.up = {0, 1, 0}; cam.position = {0, 0, 6}; cam.target = {0, 0, 0}; cam.up = {0, 1, 0};
cam.fovy = 45; cam.projection = CAMERA_PERSPECTIVE; cam.fovy = 45; cam.projection = CAMERA_PERSPECTIVE;
buildBorders(planet, borderR, borders, ridgeBorders); graticule = buildGraticule(); // planet-independent, safe to build before any world exists
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
graticule = buildGraticule();
buildMap2D(planet, mapRect, map2D);
phase3PromptAt = planet.cfg.phase3AfterMy;
refreshView(); // colour the freshly generated (flat) world screen = AppScreen::MainMenu;
return true; return true;
} }
@ -364,6 +362,22 @@ void Viewer::regenWorld() { // after generate(): geometry change
void Viewer::regen() { planet.generate(cfg); regenWorld(); } void Viewer::regen() { planet.generate(cfg); regenWorld(); }
void Viewer::startNewWorld() { // Main Menu "New World": validate, generate, switch screens
std::string err = validateConfig(cfg);
if (!err.empty()) { cfg = PlanetConfig{}; setStatus("Bad settings -- using defaults"); }
regen();
menuState.worldEverGenerated = true;
menuState.editingConfigField = -1; menuState.editBuffer.clear();
screen = AppScreen::World;
}
void Viewer::commitMenuField() { // parse menuState.editBuffer, apply to the focused config field
const auto& t = configFieldTable();
if (menuState.editingConfigField < 0 || menuState.editingConfigField >= (int)t.size()) return;
try { t[menuState.editingConfigField].set(cfg, std::stod(menuState.editBuffer)); } catch (...) {}
menuState.editingConfigField = -1; menuState.editBuffer.clear();
}
void Viewer::stepOnce() { // one tick + settle bookkeeping void Viewer::stepOnce() { // one tick + settle bookkeeping
maxChange = planet.step(); ++stepCount; maxChange = planet.step(); ++stepCount;
if (maxChange < settleThresh) { if (++settleRun >= settleNeed) settled = true; } if (maxChange < settleThresh) { if (++settleRun >= settleNeed) settled = true; }
@ -552,6 +566,26 @@ void Viewer::focusCell(int idx, const std::string& status) {
if (!status.empty()) setStatus(status); if (!status.empty()) setStatus(status);
} }
// F5 / the toolbar "Save" button always write a fresh timestamped file (mirrors the
// map/atlas export naming scheme, ViewerRender.cpp); F9 / "Load" pick the newest one.
std::string makeTimestampedSavePath() {
time_t now = time(nullptr);
struct tm tmv; localtime_r(&now, &tmv);
char name[64];
strftime(name, sizeof(name), "world_%Y-%m-%d_%H%M%S.save", &tmv);
return name;
}
std::vector<std::string> listSaveFiles() {
FilePathList files = LoadDirectoryFilesEx(".", ".save", false);
std::vector<std::string> out;
out.reserve(files.count);
for (unsigned i = 0; i < files.count; ++i) out.emplace_back(files.paths[i]);
UnloadDirectoryFiles(files);
std::sort(out.begin(), out.end(), std::greater<>()); // fixed timestamp format sorts chronologically
return out;
}
// F5: write seed + config + full planet state. F9: read it back and resume. // F5: write seed + config + full planet state. F9: read it back and resume.
void Viewer::saveGame(const char* path) { void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary); std::ofstream os(path, std::ios::binary);
@ -614,6 +648,25 @@ void Viewer::saveGame(const char* path) {
os.write((char*)&e.sourceId, 4); os.write((char*)&e.sourceId, 4);
wS(e.title); wS(e.detail); wS(e.title); wS(e.detail);
} }
// v25: the viewer's colour mode + overlay toggles, so a load restores what you were looking at,
// not just the simulation. Camera angle / map zoom-pan / selected cell stay session-local.
uint8_t modeByte = (uint8_t)mode;
os.write((char*)&modeByte, 1);
uint16_t overlayBits = 0;
overlayBits |= showBorders ? 1u << 0 : 0;
overlayBits |= showDrift ? 1u << 1 : 0;
overlayBits |= showRivers ? 1u << 2 : 0;
overlayBits |= showGrat ? 1u << 3 : 0;
overlayBits |= dayNightOn ? 1u << 4 : 0;
overlayBits |= showTides ? 1u << 5 : 0;
overlayBits |= showCurrents ? 1u << 6 : 0;
overlayBits |= showClouds ? 1u << 7 : 0;
overlayBits |= showVolcanoes ? 1u << 8 : 0;
overlayBits |= showNames ? 1u << 9 : 0;
overlayBits |= showSettlements ? 1u << 10 : 0;
overlayBits |= showDiplomacy ? 1u << 11 : 0;
overlayBits |= showRealms ? 1u << 12 : 0;
os.write((char*)&overlayBits, sizeof overlayBits);
setStatus(os ? std::string("Saved ") + path : "Save failed"); setStatus(os ? std::string("Saved ") + path : "Save failed");
} }
@ -759,6 +812,13 @@ void Viewer::loadGame(const char* path) {
} }
if (!eventsOk) { events.clear(); nextEventId = 1; skippedHistory = true; } if (!eventsOk) { events.clear(); nextEventId = 1; skippedHistory = true; }
} }
// v25: colour mode + overlay toggles, written at the very end of saveGame() (after the events
// block), so read them from the same position here.
uint8_t modeByte = 0; uint16_t overlayBits = 0;
if (ver >= 25) {
is.read(reinterpret_cast<char*>(&modeByte), 1);
is.read(reinterpret_cast<char*>(&overlayBits), sizeof overlayBits);
}
paused = true; selectedCell = -1; subgrids.clear(); paused = true; selectedCell = -1; subgrids.clear();
// Pre-v14 save already in Live World: it has no volcano block, so place a set now (v14+ saves // Pre-v14 save already in Live World: it has no volcano block, so place a set now (v14+ saves
// restore their own). A non-live save places them when the user first presses W. // restore their own). A non-live save places them when the user first presses W.
@ -767,7 +827,27 @@ void Viewer::loadGame(const char* path) {
buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildDriftArrows(planet, driftR, driftArrows, plateLabels);
buildMap2D(planet, mapRect, map2D); buildMap2D(planet, mapRect, map2D);
if (planet.settlementsPlaced()) rebuildTerritory(); // territory/nations are derived -> recompute if (planet.settlementsPlaced()) rebuildTerritory(); // territory/nations are derived -> recompute
// v25: restore the colour mode + overlay toggles. Pre-v25 saves leave them at whatever the
// Viewer already held (same "session state survives a load" behaviour saves have always had).
if (ver >= 25) {
if (modeByte <= (uint8_t)ColorMode::Seasonality) mode = (ColorMode)modeByte;
showBorders = overlayBits & (1u << 0);
showDrift = overlayBits & (1u << 1);
showRivers = overlayBits & (1u << 2);
showGrat = overlayBits & (1u << 3);
dayNightOn = overlayBits & (1u << 4);
showTides = overlayBits & (1u << 5);
showCurrents = overlayBits & (1u << 6);
showClouds = overlayBits & (1u << 7);
showVolcanoes = overlayBits & (1u << 8);
showNames = overlayBits & (1u << 9);
showSettlements = overlayBits & (1u << 10);
showDiplomacy = overlayBits & (1u << 11);
showRealms = overlayBits & (1u << 12);
}
refreshView(); refreshView();
menuState.worldEverGenerated = true; // a loaded world can always be returned to from the Main Menu
screen = AppScreen::World;
setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)" setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)"
: std::string("Loaded ") + path); : std::string("Loaded ") + path);
} }
@ -1104,9 +1184,11 @@ Rectangle Viewer::mapViewRect() const {
void Viewer::run() { void Viewer::run() {
while (!WindowShouldClose()) { while (!WindowShouldClose()) {
handleInput(); handleInput();
if (screen == AppScreen::World) {
stepSim(); stepSim();
// A regenerate this frame may have shrunk the planet; keep indices valid. // A regenerate this frame may have shrunk the planet; keep indices valid.
if (hovered >= (int)planet.cells.size()) hovered = -1; if (hovered >= (int)planet.cells.size()) hovered = -1;
}
renderFrame(); renderFrame();
} }
UnloadRenderTexture(rt3d); UnloadRenderTexture(rt3d);

View File

@ -5,6 +5,7 @@
#include "Overlays.hpp" // PlateLabel #include "Overlays.hpp" // PlateLabel
#include "Map2D.hpp" // Map2D #include "Map2D.hpp" // Map2D
#include "Panels.hpp" // EditField, EditPanelState #include "Panels.hpp" // EditField, EditPanelState
#include "MainMenu.hpp" // AppScreen, MenuTab, MainMenuState
#include <vector> #include <vector>
#include <string> #include <string>
#include <memory> #include <memory>
@ -16,13 +17,16 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 24; // v24: edit mode (per-cell lock bits + locked-value maps); v23: civ cultural evolution; v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr uint32_t SAVE_VERSION = 25; // v25: +viewer colour-mode/overlay state (Viewer::saveGame/loadGame only, not Planet); v24: edit mode (per-cell lock bits + locked-value maps); v23: civ cultural evolution; v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save
static constexpr int EVENT_LOG_MAX = 200; static constexpr int EVENT_LOG_MAX = 200;
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides) std::string configPath = "planet.cfg"; // initial config (--config overrides)
// ---- App screen (Main Menu vs. a live world) ----------------------------
AppScreen screen = AppScreen::MainMenu; // init() no longer auto-generates a world
MainMenuState menuState;
// ---- Window / layout (set in init) -------------------------------------- // ---- Window / layout (set in init) --------------------------------------
int screenW = 1920, screenH = 1080; int screenW = 1920, screenH = 1080;
int leftW = 0, rightX = 0, rightW = 0, rightH = 0; int leftW = 0, rightX = 0, rightW = 0, rightH = 0;
@ -211,6 +215,8 @@ struct Viewer {
const std::vector<Color>& displayColors() const { return liveWorld ? shadedColors : vcolors; } const std::vector<Color>& displayColors() const { return liveWorld ? shadedColors : vcolors; }
void regenWorld(); // after generate(): geometry changed void regenWorld(); // after generate(): geometry changed
void regen(); // generate(cfg) + regenWorld() void regen(); // generate(cfg) + regenWorld()
void startNewWorld(); // validate cfg, regen(), switch screen to World (Main Menu "New World")
void commitMenuField(); // parse menuState.editBuffer and apply it to the focused config field
void stepOnce(); // one tick + settle bookkeeping void stepOnce(); // one tick + settle bookkeeping
void pauseAction(); void pauseAction();
void setStatus(const std::string& m); void setStatus(const std::string& m);
@ -278,3 +284,8 @@ struct Viewer {
void renderHUD(); void renderHUD();
void renderPrompt(); void renderPrompt();
}; };
// Free helpers for the timestamped save scheme (no Viewer state needed):
// F5 / the toolbar "Save" button always write a fresh file; F9 / "Load" pick the newest.
std::string makeTimestampedSavePath(); // "world_YYYY-MM-DD_HHMMSS.save"
std::vector<std::string> listSaveFiles(); // *.save in the working dir, newest first

View File

@ -2,6 +2,7 @@
#include "Picking.hpp" #include "Picking.hpp"
#include "Map2D.hpp" // wrapPi #include "Map2D.hpp" // wrapPi
#include "Projection.hpp" // EqualEarth, lonLatToDir #include "Projection.hpp" // EqualEarth, lonLatToDir
#include "MainMenu.hpp" // handleMainMenuInput
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@ -10,6 +11,7 @@
// members (mp/onPause/hovered/hasHoverSub/hoverSub/hoveredSubIdx) for the render // members (mp/onPause/hovered/hasHoverSub/hoverSub/hoveredSubIdx) for the render
// pass, and may step / regenerate / save / load the world. // pass, and may step / regenerate / save / load the world.
void Viewer::handleInput() { void Viewer::handleInput() {
if (screen == AppScreen::MainMenu) { handleMainMenuInput(*this); return; }
mp = GetMousePosition(); mp = GetMousePosition();
bool in3D = (mp.x < view3DW && mp.y < view3DH); // top-left quadrant bool in3D = (mp.x < view3DW && mp.y < view3DH); // top-left quadrant
bool inMap = CheckCollisionPointRec(mp, mapRect); bool inMap = CheckCollisionPointRec(mp, mapRect);
@ -241,8 +243,12 @@ void Viewer::handleInput() {
regen(); regen();
setStatus(cerr.empty() ? std::string("Reloaded ") + configPath : "Bad config — using defaults"); setStatus(cerr.empty() ? std::string("Reloaded ") + configPath : "Bad config — using defaults");
} else setStatus(std::string("No ") + configPath); } } else setStatus(std::string("No ") + configPath); }
if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH); if (IsKeyPressed(KEY_F5)) saveGame(makeTimestampedSavePath().c_str());
if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH); if (IsKeyPressed(KEY_F9)) {
auto files = listSaveFiles();
if (!files.empty()) loadGame(files.front().c_str());
else setStatus("No saves found");
}
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); } if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }
if (IsKeyPressed(KEY_F11)) { if (IsKeyPressed(KEY_F11)) {
if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) exportAtlasImage(); if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) exportAtlasImage();

View File

@ -3,6 +3,7 @@
#include "Map2D.hpp" #include "Map2D.hpp"
#include "Panels.hpp" #include "Panels.hpp"
#include "Toolbar.hpp" // drawToolbar #include "Toolbar.hpp" // drawToolbar
#include "MainMenu.hpp" // drawMainMenu
#include "Picking.hpp" // rotateZ (axial-tilt transform for labels) #include "Picking.hpp" // rotateZ (axial-tilt transform for labels)
#include "rlgl.h" #include "rlgl.h"
#include "Projection.hpp" // dirToLonLat (plate labels) #include "Projection.hpp" // dirToLonLat (plate labels)
@ -1169,6 +1170,15 @@ void Viewer::renderPrompt() {
// One full frame: globe texture, then composite + 3D labels + map + panels + // One full frame: globe texture, then composite + 3D labels + map + panels +
// HUD + prompt onto the screen. // HUD + prompt onto the screen.
void Viewer::renderFrame() { void Viewer::renderFrame() {
// A complete early return, not a dim overlay over a running world (unlike the Phase-3 modal
// below) -- the Main Menu must render before any world/planet.cells even exists.
if (screen == AppScreen::MainMenu) {
BeginDrawing();
ClearBackground(Color{8, 10, 16, 255});
drawMainMenu(*this);
EndDrawing();
return;
}
renderGlobe3D(); renderGlobe3D();
BeginDrawing(); BeginDrawing();

View File

@ -483,3 +483,19 @@ private:
bool loadConfig(const std::string& path, PlanetConfig& cfg); bool loadConfig(const std::string& path, PlanetConfig& cfg);
bool saveConfig(const std::string& path, const PlanetConfig& cfg); bool saveConfig(const std::string& path, const PlanetConfig& cfg);
std::string validateConfig(const PlanetConfig& cfg); std::string validateConfig(const PlanetConfig& cfg);
// One entry per CONFIG_FIELDS token (see PlanetIO.cpp), in PlanetConfig declaration order, built
// once at first use. Lets a single generic UI loop (the Main Menu settings editor) drive all ~240
// config fields instead of 240 hand-written widgets -- the same "add a field, everything else keeps
// working" property writeConfigFields()/parseConfigStream() already have for planet.cfg/saves.
struct ConfigFieldDesc {
const char* name; // matches the CONFIG_FIELDS token, e.g. "seaLevel"
const char* category; // section label, e.g. "Tectonic tuning"
bool isInteger; // true for I()/U() fields (whole-number display/parse)
double (*get)(const PlanetConfig&);
void (*set)(PlanetConfig&, double);
};
const std::vector<ConfigFieldDesc>& configFieldTable();
// The distinct ConfigFieldDesc::category labels, in display order (25 groups) -- the Main Menu
// settings editor's left-rail category list.
const std::vector<const char*>& configCategoryList();

View File

@ -10,6 +10,7 @@
#include <limits> #include <limits>
#include <set> #include <set>
#include <utility> #include <utility>
#include <cstring>
// --- Config file (text) + save/load (binary) -------------------------------- // --- Config file (text) + save/load (binary) --------------------------------
@ -115,6 +116,206 @@ static void parseConfigStream(std::istream& is, PlanetConfig& cfg) {
} }
} }
// Category assignment: an explicit name->category table, built by hand from PlanetConfig's actual
// struct declaration order in PlanetTypes.hpp (25 "// ---" / plain-comment sections; "Plate
// lifecycle" absorbs the trailing "Spreading & volcanic (legacy)" block and "Cultural evolution"
// absorbs "Dawn seeding", matching how CLAUDE.md documents those as one knob group each). File
// scope (not local to buildConfigFieldTable()) so configCategoryList() can share it below.
//
// This CANNOT be a "first field of each section" boundary scan over configFieldTable()'s own
// iteration order -- CONFIG_FIELDS expands to all D() fields, then all I() fields, then U(seed),
// so within a section whose fields mix types (nearly all of them) the section's members are
// scattered across two separate runs of the table, not contiguous. A name->category lookup sidesteps
// that entirely. This array IS written in one contiguous block per category, though (unlike
// configFieldTable()), so it doubles as the source of truth for configCategoryList()'s display order.
static const struct { const char* name; const char* category; } kConfigFieldCategory[] = {
{"radius", "Core"}, {"subdivisions", "Core"}, {"seaLevel", "Core"}, {"plateCount", "Core"},
{"seed", "Core"}, {"axialTilt", "Core"},
{"continentBase", "Tectonic tuning"}, {"oceanBase", "Tectonic tuning"},
{"upliftGain", "Tectonic tuning"}, {"beltWidth", "Tectonic tuning"}, {"relax", "Tectonic tuning"},
{"collisionFactor", "Orogeny"}, {"arcFactor", "Orogeny"}, {"isostaticPersist", "Orogeny"},
{"rootScale", "Orogeny"},
{"peakSoftCapStart", "Soft peak cap"}, {"peakSoftCapEnd", "Soft peak cap"},
{"peakFailDrop", "Soft peak cap"},
{"maxDriftSpeed", "Plate drift"}, {"ridgeDepth", "Plate drift"},
{"seafloorSubsidence", "Seafloor aging"}, {"seafloorSeedAge", "Seafloor aging"},
{"splitCheckEvery", "Plate lifecycle"}, {"splitFraction", "Plate lifecycle"},
{"splitProbBase", "Plate lifecycle"}, {"splitProbSlope", "Plate lifecycle"},
{"stalemateEps", "Plate lifecycle"}, {"stalemateWindows", "Plate lifecycle"},
{"stalemateBoost", "Plate lifecycle"}, {"miniPlateCells", "Plate lifecycle"},
{"fuseMinPlates", "Plate lifecycle"}, {"babyMinCells", "Plate lifecycle"},
{"babyPromoteFrac", "Plate lifecycle"}, {"volcanicLandFrac", "Plate lifecycle"},
{"volcanicElev", "Plate lifecycle"}, {"landBand", "Plate lifecycle"},
{"erosionLandRate", "Erosion & sea level"}, {"erosionSeaRate", "Erosion & sea level"},
{"landFractionTarget", "Erosion & sea level"}, {"seaLevelStep", "Erosion & sea level"},
{"seaLevelTol", "Erosion & sea level"}, {"seaLevelEvery", "Erosion & sea level"},
{"phase3AfterMy", "Hydrology"}, {"phase3DtScale", "Hydrology"}, {"rainfall", "Hydrology"},
{"riverThreshold", "Hydrology"}, {"riverIncision", "Hydrology"},
{"riverDischargeExp", "Hydrology"}, {"riverSlopeExp", "Hydrology"},
{"riverTransport", "Hydrology"}, {"depFrac", "Hydrology"},
{"biomeEquatorTemp", "Biome classification"}, {"biomePoleDrop", "Biome classification"},
{"biomeLatExp", "Biome classification"}, {"biomeElevLapse", "Biome classification"},
{"biomeIceTemp", "Biome classification"}, {"biomeTundraTemp", "Biome classification"},
{"biomeTaigaTemp", "Biome classification"}, {"biomeSavannaTemp", "Biome classification"},
{"biomeMountainElev", "Biome classification"}, {"biomeHillsElev", "Biome classification"},
{"biomeBeachBand", "Biome classification"}, {"biomeLowlandElev", "Biome classification"},
{"biomeWetlandMoist", "Biome classification"}, {"biomeDesertMoist", "Biome classification"},
{"biomeGrassMoist", "Biome classification"}, {"biomeTaigaMoist", "Biome classification"},
{"biomeLakeMinDepth", "Biome classification"}, {"biomeSeasonWeight", "Biome classification"},
{"climateOceanMoisture", "Climate"}, {"climateRainEfficiency", "Climate"},
{"climateOrographic", "Climate"}, {"climateOroRefHeight", "Climate"},
{"climateContinentality", "Climate"}, {"climateWindPasses", "Climate"},
{"climateMoistureSmooth", "Climate"}, {"climateCurrentFactor", "Climate"},
{"seasonAmpMax", "Seasons"}, {"seasonLatExp", "Seasons"}, {"seasonOceanFactor", "Seasons"},
{"seasonContinentRings", "Seasons"},
{"bioVegTempMin", "Biota"}, {"bioVegTempOpt", "Biota"}, {"bioVegMoistRef", "Biota"},
{"bioFaunaProductivity", "Biota"}, {"bioCarnPreyMin", "Biota"}, {"bioCarnScale", "Biota"},
{"bioFungaMoistRef", "Biota"}, {"bioFungaFloraWeight", "Biota"}, {"bioFungaTempMin", "Biota"},
{"bioRegionBonus", "Biota"}, {"bioMarineBase", "Biota"}, {"bioMarineShelfDepth", "Biota"},
{"bioMarineCoastRings", "Biota"}, {"bioFloraSlots", "Biota"}, {"bioFaunaSlots", "Biota"},
{"bioFungaSlots", "Biota"}, {"bioFloraPoints", "Biota"}, {"bioFaunaPoints", "Biota"},
{"bioFungaPoints", "Biota"},
{"dayLengthHours", "Live World"}, {"yearLengthDays", "Live World"}, {"snowTemp", "Live World"},
{"seaIceTemp", "Live World"}, {"tideAmplitude", "Live World"}, {"tideSunFactor", "Live World"},
{"weatherEvapRate", "Weather"}, {"weatherWindKmh", "Weather"}, {"weatherSatBase", "Weather"},
{"weatherSatTempCoef", "Weather"}, {"weatherCondense", "Weather"},
{"weatherOrographic", "Weather"}, {"weatherRainThresh", "Weather"},
{"weatherRainRate", "Weather"}, {"weatherCloudDissip", "Weather"},
{"weatherSystemMax", "Weather systems"}, {"weatherSpawnRate", "Weather systems"},
{"weatherSystemSpeed", "Weather systems"}, {"weatherTropicalSST", "Weather systems"},
{"weatherSystemRadius", "Weather systems"}, {"weatherSystemCloud", "Weather systems"},
{"weatherSystemRain", "Weather systems"}, {"weatherHurricaneStr", "Weather systems"},
{"volcanoProbRidge", "Volcanoes"}, {"volcanoProbBorder", "Volcanoes"},
{"volcanoProbInterior", "Volcanoes"}, {"volcanoMaxCount", "Volcanoes"},
{"volcanoBuildRate", "Volcanoes"}, {"volcanoFreeHeight", "Volcanoes"},
{"volcanoInitialBuildMax", "Volcanoes"}, {"volcanoMaxHeight", "Volcanoes"},
{"volcanoDormancyRate", "Volcanoes"}, {"volcanoDormantMinYears", "Volcanoes"},
{"volcanoDormantMaxYears", "Volcanoes"}, {"volcanoExplodeDropFrac", "Volcanoes"},
{"volcanoActivityDecay", "Volcanoes"}, {"volcanoDeadActivity", "Volcanoes"},
{"volcanoBlastRadius", "Volcanoes"}, {"volcanoBlastCloud", "Volcanoes"},
{"volcanoAshMinYears", "Volcanoes"}, {"volcanoAshMaxYears", "Volcanoes"},
{"volcanoAshPuffCellsPerWeek", "Volcanoes"}, {"volcanoAshCloud", "Volcanoes"},
{"volcanoAshCooling", "Volcanoes"},
{"geoContinentMinCells", "Geography"}, {"geoSeaMaxCells", "Geography"},
{"geoOceanSepRadians", "Geography"}, {"geoOceanDeep", "Geography"},
{"geoMountainElev", "Geography"}, {"geoRangeMinCells", "Geography"},
{"geoRiverMinDischarge", "Geography"}, {"geoMaxRivers", "Geography"}, {"geoMaxPeaks", "Geography"},
{"civMaxSettlements", "Civilization & settlements"}, {"civMinSpacingRadians", "Civilization & settlements"},
{"civClusterExp", "Civilization & settlements"}, {"civMinHabitability", "Civilization & settlements"},
{"civSeedPopulation", "Civilization & settlements"}, {"civGrowthRate", "Civilization & settlements"},
{"civMaxPopulation", "Civilization & settlements"}, {"civTownPop", "Civilization & settlements"},
{"civCityPop", "Civilization & settlements"}, {"civAbandonPop", "Civilization & settlements"},
{"civHabWaterWeight", "Civilization & settlements"}, {"civHabFoodWeight", "Civilization & settlements"},
{"civHabTempOpt", "Civilization & settlements"}, {"civHabElevPenalty", "Civilization & settlements"},
{"civSiteVariety", "Civilization & settlements"}, {"civGrowthMin", "Civilization & settlements"},
{"civHarvestVar", "Civilization & settlements"}, {"civDroughtStrength", "Civilization & settlements"},
{"civDroughtPeriod", "Civilization & settlements"}, {"civDroughtThresh", "Civilization & settlements"},
{"civDroughtArid", "Civilization & settlements"}, {"civColdYearStrength", "Civilization & settlements"},
{"civFloodBonus", "Civilization & settlements"}, {"civFamineRate", "Civilization & settlements"},
{"civStormDeathRate", "Civilization & settlements"}, {"civHurricaneDeathMult", "Civilization & settlements"},
{"civMetropolisPop", "Civilization & settlements"}, {"civCrowdingLoss", "Civilization & settlements"},
{"civCondBoomCap", "Civilization & settlements"}, {"civPlagueRate", "Civilization & settlements"},
{"civPlagueDeathMin", "Civilization & settlements"}, {"civPlagueDeathMax", "Civilization & settlements"},
{"civPlagueTradeWeight", "Civilization & settlements"},
{"civTerritoryBase", "Territory & nations"}, {"civTerritoryScale", "Territory & nations"},
{"civTerritoryMax", "Territory & nations"}, {"civVassalRange", "Territory & nations"},
{"civEmpireMinMembers", "Territory & nations"}, {"civEmpirePop", "Territory & nations"},
{"warMaxConcurrent", "Conflict & war"}, {"warDeclareRate", "Conflict & war"},
{"warAmbition", "Conflict & war"}, {"warIdeology", "Conflict & war"}, {"warBorder", "Conflict & war"},
{"warWarlikeMult", "Conflict & war"}, {"warCasualtyRate", "Conflict & war"},
{"warConquerScore", "Conflict & war"}, {"warSackChance", "Conflict & war"},
{"warExhaustion", "Conflict & war"}, {"warRevoltRate", "Conflict & war"},
{"warMinRealmPop", "Conflict & war"},
{"diploDriftRate", "Diplomacy"}, {"diploAffinity", "Diplomacy"}, {"diploWarPenalty", "Diplomacy"},
{"diploTruceYears", "Diplomacy"}, {"diploWarGrudge", "Diplomacy"},
{"diploAllyThreshold", "Diplomacy"}, {"diploNonAggThreshold", "Diplomacy"},
{"diploRivalThreshold", "Diplomacy"},
{"tradeLandRange", "Trade & economy"}, {"tradeSeaRange", "Trade & economy"},
{"tradeRiverBonus", "Trade & economy"}, {"tradeMinVolume", "Trade & economy"},
{"tradeProsperityWeight", "Trade & economy"}, {"tradeWarBlock", "Trade & economy"},
{"tradeAllyBonus", "Trade & economy"}, {"tradeDiploBonus", "Trade & economy"},
{"tradeTemptWar", "Trade & economy"},
{"civColonizeRate", "Colonization"}, {"civColonyMinPop", "Colonization"},
{"civColonyMinHab", "Colonization"}, {"civColonyReach", "Colonization"},
{"civColonySeaReach", "Colonization"}, {"civColonySpacing", "Colonization"},
{"civMaxColonies", "Colonization"}, {"civColonySupply", "Colonization"},
{"cultAssimRate", "Cultural evolution"}, {"cultConvertRate", "Cultural evolution"},
{"cultConvertDominance", "Cultural evolution"}, {"cultSpreadRange", "Cultural evolution"},
{"cultPrestigeWeight", "Cultural evolution"}, {"cultSchismMinMembers", "Cultural evolution"},
{"cultSchismRange", "Cultural evolution"}, {"cultSchismMinCluster", "Cultural evolution"},
{"cultSchismRate", "Cultural evolution"}, {"cultDawnSettlementsPerCulture", "Cultural evolution"},
{"cultDawnMaxPerContinent", "Cultural evolution"},
};
// A runtime-iterable field table built from the same CONFIG_FIELDS macro, once, so a single
// generic UI loop (the Main Menu settings editor) can drive all ~240 fields instead of 240
// hand-written widgets -- the same "add a field, everything else keeps working" property
// writeConfigFields()/parseConfigStream() already give planet.cfg/saves above.
static std::vector<ConfigFieldDesc> buildConfigFieldTable() {
std::vector<ConfigFieldDesc> t;
#define FIELD_D(name) t.push_back({#name, "", false, \
[](const PlanetConfig& c){ return c.name; }, \
[](PlanetConfig& c, double v){ c.name = v; }});
#define FIELD_I(name) t.push_back({#name, "", true, \
[](const PlanetConfig& c){ return (double)c.name; }, \
[](PlanetConfig& c, double v){ c.name = (int)std::lround(v); }});
#define FIELD_U(name) t.push_back({#name, "", true, \
[](const PlanetConfig& c){ return (double)c.name; }, \
[](PlanetConfig& c, double v){ c.name = (uint32_t)std::llround(v); }});
CONFIG_FIELDS(FIELD_D, FIELD_I, FIELD_U)
#undef FIELD_D
#undef FIELD_I
#undef FIELD_U
for (auto& d : t) {
d.category = "Uncategorized"; // must never survive: every name appears in kConfigFieldCategory
for (const auto& c : kConfigFieldCategory)
if (std::strcmp(d.name, c.name) == 0) { d.category = c.category; break; }
}
return t;
}
const std::vector<ConfigFieldDesc>& configFieldTable() {
static const std::vector<ConfigFieldDesc> t = buildConfigFieldTable();
return t;
}
// The distinct category labels in kConfigFieldCategory's own written order (which, unlike
// configFieldTable(), IS one contiguous block per category) -- the Main Menu settings editor's
// left-rail category order.
const std::vector<const char*>& configCategoryList() {
static const std::vector<const char*> cats = [] {
std::vector<const char*> out;
for (const auto& c : kConfigFieldCategory)
if (out.empty() || std::strcmp(out.back(), c.category) != 0) out.push_back(c.category);
return out;
}();
return cats;
}
bool saveConfig(const std::string& path, const PlanetConfig& cfg) { bool saveConfig(const std::string& path, const PlanetConfig& cfg) {
std::ofstream os(path); std::ofstream os(path);
if (!os) return false; if (!os) return false;

80
test_viewerstate.cpp Normal file
View File

@ -0,0 +1,80 @@
// Headless checks for the v25 viewer colour-mode/overlay save block (no window needed).
// Modeled on test_events.cpp.
#include "Viewer.hpp"
#include <cstdio>
#include <fstream>
#include <string>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
int main() {
std::printf("Viewer state: v25 colour-mode/overlay save/load round-trip\n");
Viewer v;
PlanetConfig cfg; cfg.subdivisions = 2; cfg.seed = 777;
v.cfg = cfg;
v.planet.generate(cfg);
// Set every overlay to a value that differs from the Viewer's own defaults, plus a non-default
// colour mode, so a bug that left fields untouched (rather than round-tripped) would show up.
v.mode = ColorMode::Biome;
v.showBorders = false; // default true
v.showDrift = false; // default true
v.showRivers = false; // default true
v.showGrat = true; // default false
v.dayNightOn = false; // default true
v.showTides = true; // default false
v.showCurrents = true; // default false
v.showClouds = false; // default true
v.showVolcanoes = false; // default true
v.showNames = true; // default false
v.showSettlements = false; // default true
v.showDiplomacy = false; // default true
v.showRealms = true; // default false
const char* path = "/tmp/fanworgen_viewerstate_test.save";
v.saveGame(path);
Viewer r;
r.loadGame(path);
check(r.mode == ColorMode::Biome, "v25 save/load restores the colour mode");
check(r.showBorders == false, "v25 restores showBorders");
check(r.showDrift == false, "v25 restores showDrift");
check(r.showRivers == false, "v25 restores showRivers");
check(r.showGrat == true, "v25 restores showGrat");
check(r.dayNightOn == false, "v25 restores dayNightOn");
check(r.showTides == true, "v25 restores showTides");
check(r.showCurrents == true, "v25 restores showCurrents");
check(r.showClouds == false, "v25 restores showClouds");
check(r.showVolcanoes == false, "v25 restores showVolcanoes");
check(r.showNames == true, "v25 restores showNames");
check(r.showSettlements == false, "v25 restores showSettlements");
check(r.showDiplomacy == false, "v25 restores showDiplomacy");
check(r.showRealms == true, "v25 restores showRealms");
check(r.screen == AppScreen::World, "loadGame() switches the app screen to World");
check(r.menuState.worldEverGenerated, "loadGame() marks a world as having been generated");
// Negative test: patch the save's version back to 24 (pre-v25, no mode/overlay block) and load
// into a fresh Viewer whose defaults differ from the saved-but-truncated values above -- must
// not crash, and must leave mode/overlays exactly at the fresh Viewer's own pre-load values.
{
std::fstream fs(path, std::ios::in | std::ios::out | std::ios::binary);
uint32_t oldVer = 24;
fs.seekp(4);
fs.write((char*)&oldVer, 4);
}
Viewer old;
ColorMode preLoadMode = old.mode;
bool preLoadBorders = old.showBorders, preLoadRealms = old.showRealms;
old.loadGame(path);
check(old.mode == preLoadMode, "pre-v25 saves leave the colour mode at its pre-load value");
check(old.showBorders == preLoadBorders, "pre-v25 saves leave showBorders at its pre-load value");
check(old.showRealms == preLoadRealms, "pre-v25 saves leave showRealms at its pre-load value");
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL VIEWER STATE CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}