Frontend redesign - #18
Closed
valentinps wants to merge 15 commits into
Closed
Conversation
Nothing in a save (or docs.json) records a cave, so the source is the cooked world-partition export: the game's own cave atmosphere volumes (the fog/lighting regions it swaps you into underground -- 108 of them, some with authored names), the BP_CaveFloor tunnel splines, the cave-only foliage clusters and the placed cave rock kit. extract_caves.py unions all four, closes the gaps between one cave's separately-authored chambers, and traces the outline of each connected component into the committed game_data/sav_data/caves.json (84 caves, 61 KB). The core embeds that table and emits it as a new static payload step, and the frontend draws every ring in one line bucket under a "Caves" sidebar row (default hidden like the other world layers), with the cave's name, area and altitude range in the tooltip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two invisible edges, both authored level geometry with no representation in a save: - The damaging border is 15 FGDamageOverTimeVolume actors whose DoT class comes from /World/Hazard/WorldPerimeter/. Eleven are vertical walls (four axis-aligned, three rotated blocks cutting the NE/NW/SW corners, four more as an outer backstop); the safe side is a 7-vertex polygon, 7915 x 7049 m. The other four span the whole world in XY and are altitude limits instead: damage above +2000 m and below -244 m. - The water limit is the union of the 270 FGWaterVolume actors (all carry mResourceClass Desc_Water_C, i.e. swimmable and extractor-valid). The rendered ocean is a different thing entirely: 31 water-plane patches, the biggest 51 x 34 km, so the sea you can see runs ~70 km while the water you can touch stops within ~8 km -- on the west side the real water ends 334 m before the damage wall, with visual-only ocean in between. The perimeter polygon is written as an exact half-plane intersection of the wall boxes and cross-checked both ways against an independent rasterize-and- trace of the same volumes (they agree to 40 cm shy of a cell). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a "Map Limits" sidebar category (default hidden, like the other world layers) with one row per ring: the world border in red, the water limit in cyan, each a line bucket with the facts in its tooltip -- the altitudes the damage slabs start at for the border, and "8.2 km of real water inside a 70 km ocean" for the water. The rings ride along as a new static payload step fed by the embedded worldBounds.json, the same shape as the spawners step. Two things worth knowing for the next static layer: - ring z is 0 (sea level), not the volumes' real -9.6..+10.4 km span: a line's z only drives the altitude filter and depth sorting, so the honest span would just make the tooltip claim the border sits 10 km up. - synthetic ids need a ':' in them. Bulk id arrays get the instance-name prefix re-added client-side (slim_payload_value / expandPayloadIds), which silently turned "worldPerimeter" into "Persistent_Level:PersistentLevel. worldPerimeter" and broke the color lookup keyed off it. The frontend now keys off a separate `kinds` array, and a test pins the ':' rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caves outlines, the damaging world border and the water limit are three answers to the same question -- what is the terrain actually like here? -- and none of them belongs to the save being viewed, so they now share one sidebar category instead of two. Three rows, three colors, one checkbox each; "Map Limits" is gone as a name. Payload steps stay separate (caves + mapLimits), so the collectors and their tests are untouched apart from the step count going to 20.
The old split was game_data/sav_data/ (committed) vs game_data/generated/
(gitignored), which read as "world tables vs docs.json extracts" but was
really "the IP line we drew", and membership had drifted: creatureSpawners
(coordinates, like the committed slugs) was gitignored, half the hand-written
tables sat in sav_data/ and half at game_data/ root, and two generated files
had no consumer at all.
Now:
game_data/curated/ committed hand-maintained inputs only
game_data/generated/docs/ gitignored Docs.json extracts
game_data/generated/world/ gitignored level-export tables
game_data/generated/ gitignored map render + tiles
so a checkout is either extracted (extract_all.py) or unpacked
(package_game_data.py unpack), never half-committed. game_data/README.md
states the rule where the folders are; sav_data/ is gone, and so are
sav_data_gaps.json (no consumers) and the flat generated/*.json layout.
Two things had to be fixed for "nothing generated is committed" to actually
hold, both of which were latent bugs in the old layout:
- freeDroppedItems.json recovered each pickup's item class from its OWN last
output (FModel can't decode the cooked FInventoryItem struct). Delete the
file and all 703 pickups lose their item. That knowledge now lives in the
committed curated/pickupItems.json, an input; --items-from-save writes what
it learns back into it.
- every table's key order was inherited from the previously committed file,
so a fresh machine produced different bytes than an incremental re-run.
Tables are now written sorted: same dump in, byte-identical files out.
Verified by deleting generated/{docs,world}/ and re-running extract_all --
every table came back byte-identical to the pre-reorg ones.
Also: package_game_data packs generated/ recursively so the new subfolders
need no change there, but an archive built before this commit carries no
world/ tables (they used to be committed), so CI now points at game-data-v3
-- publish the rebuilt zip under that tag or CI cannot compile the crate.
sav_core's build script now says which command to run when the data is
missing instead of failing on the first include_str!.
NOTICES/README: the distribution clause said game-derived data is "not
distributed here" while CI downloaded exactly that from a release. Reworded
to say what is true -- none of it is in the repository, and the archive is a
convenience build of your own extraction.
The row now reads "Caves (approximate)", hovering it explains why, and a cave's tooltip carries an "Outline: Approximate" line where someone reading a size off it will see it. The number is honest about its source: an outline is the game's cave fog volume unioned with the cave geometry inside it, which runs generous by tens of meters and can miss a bare tunnel with no cave props in it. That finds a cave; it does not survey one, and the layer should not imply otherwise. row.hint is new on leaf rows -- the long version of a caveat a label can only gesture at, shown as a title on hover.
…atching Three separate frictions in the search bar, all of them cases where the answer was already on screen but out of reach. A collectable item's suggestion row now carries the same show/hide eye a building or layer row has, flipping that pickup's own Collectables rows. Searching "Mercer Sphere" honestly means two things -- what is stashed in inventories, and what is still lying out there -- and only the first had an answer. The eye drives the real sidebar checkboxes rather than a second state of its own, and appears on exactly the six items that have a layer at all: the three slugs, Somersloop, Mercer Sphere, Hard Drive. Every individual location in an item's result list gets a crosshair button that flies the map to that one machine and marks it with a solid pink pin (every other pin on the map is white-filled, so this one cannot be mistaken for scenery). Coordinates told you where a building was; this takes you there. It is deliberately independent of "Show only these on map" -- "where is THIS one" is worth asking without blanking every other layer for context -- and hands over to the same banner, so Details reopens the list exactly where it was. Finally, a query's words may now match anywhere in a label, in any order, so "coal node" finds "Coal Ore (Resource Node)". The parenthesised kind suffix is a real part of those names and exactly how people say them, but no contiguous substring spans it, so the old strict test could never reach past it. Tighter matches still rank first, so "iron" keeps surfacing Iron Plate ahead of Reinforced Iron Plate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map deliberately stops short of the right edge -- the altitude rail is docked there -- but the panels that float over it are body-level siblings of #map, so their left:50% was the WINDOW's center: half a rail-width (32px) too far right, at every viewport size. On its own that drift is invisible; under the search pill, which is genuinely centered on the map (#topBar reserves the same rail width in its right padding), the filter banner sat visibly off. One --map-center-x for all four of them (filter banner, selection panel, editor toolbar, editor hint), explained once where it's defined. Also moves a group header's "× 28" badge from the right edge to right after the building name: "Constructor × 28" is one phrase and now reads as one. The label gives up its flex stretch and the badge takes over the gap-filling, so the count column stays exactly where it was -- aligned with every non-grouped row's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Search: collectable layer toggles, per-location "Show on map", word-order matching
A planning tool for "where should the belts/trains/power actually run".
Reachable only by typing its name in the search bar (a new "tool" catalog
entry, so it costs no permanent UI), it takes a set of points -- clicked
empty map, clicked buildings, the whole rectangle selection in bulk, or
typed X/Y metres -- and draws the shortest set of links joining them.
Two link styles, both exact and both O(n log n) (map/static/map/emst.js):
Straight Euclidean MST. Sits inside the Delaunay triangulation, so a
sweep-hull triangulation cuts the candidate set from n^2/2
edges to under 3n and Kruskal does the rest.
X / Y only Rectilinear (L1) MST -- NOT the straight tree with corners
drawn on: changing the metric changes which points the optimal
tree even connects. Sits inside the octant graph, and inside
one octant the L1 distance collapses to a linear function of
the far endpoint, so four Fenwick sweeps find all eight
nearest neighbours at once.
Optionally one point is a DESTINATION, and a slider trades total network
length against how far every point has to travel through the tree to reach
it (Prim-Dijkstra: Prim with the key alpha * tree-distance + edge weight).
alpha = 0 is Prim, so still the exact MST; alpha = 1 is Dijkstra over a
candidate set augmented with the destination's own edges, so every point
joins it directly -- the exact minimum of summed trip distance. Between the
two it is a heuristic trade, and the panel says so rather than implying an
optimum that is NP-hard to find. On 5,700 real machines, 40% along the
slider costs 9% more network and cuts total travel by 47%.
Everything is computed in map pixels: the world projection is one uniform
scale with no rotation, so an optimal tree there is optimal in metres, and
"along X" on screen is "along X" in the world.
tools/check_emst.js is the gate. A short candidate set still yields a
spanning tree, just a silently longer one, so every result is diffed
against brute-force Prim over grids, circles, collinear runs, duplicates
and clusters -- plus, with a destination, that alpha=0 is still the MST,
that alpha=1 is the exact star, and that the trip distances the panel
reports match a walk of the tree it actually drew. It caught an inverted
in-circle sign on its first run. 100,000 points solve in about a second.
Also here, both found by using the tool:
- selection.js: selecting a PIN (vehicles, collectables, players, crash
sites) marked the bare coordinate, which is where the tail points -- the
pin itself, floating above it, looked untouched. Now ringed, with the
same geometry the hover highlight already uses.
- The same object can no longer be added twice: points carry the object's
save id, so clicking a 100m foundation's far corner counts as the same
object rather than a second point. Clicking it again removes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Optimal network finder (EMST): shortest links joining a set of points
The map's chrome has no unit tests, so "did that CSS change break the
building dialog at 1024px" was only answerable by looking, which meant in
practice it went unanswered.
ui_shots.py captures 17 UI states (empty, loaded, each dialog, each tool
panel, three narrow viewports) and pixel-diffs them against a previous
run, so a refactor can be checked state by state:
py tools/ui_shots.py --serve --out ui_shots/before
...change things, rebuild dist...
py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before
ui_behaviour.py covers what a screenshot cannot: that dialogs are real
modals, that the hover tooltip still paints above one, that Escape peels
exactly one layer, that opening a dock never resizes the map.
Both run against dist/ and take --serve and --headed. Their output is
gitignored; the baselines are local, since committing PNGs of the whole
UI would bloat the repo for little gain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chrome had grown one implementation per feature: four separate modal
dialogs, five progress bars, six list-row shapes, thirteen copies of the
same accent-button declaration, three private copies of el(). Each was
reasonable alone; together they meant "make the panels match" was a
manual job that was never finished. Everything also floated over the map
as free-standing cards, so a working session looked like a scatter of
unrelated windows.
Design tokens (map.css :root)
The whole colour/type/spacing/radius vocabulary in one block. 19
distinct font sizes -> 8 steps, 11 radii -> 5, 25 !important -> 0, and
~180 raw hex literals down to 17 genuine one-offs.
Primitives (ui.css + ui.js)
.btn/.field/.dlg/.row/.bar/.toggleSwitch/.chev and UI.el/UI.dialog/
UI.onEscape. ui.css loads BEFORE map.css so a feature rule of equal
specificity can override a primitive without !important.
Native <dialog>
The four modals use showModal(), which hands the browser the focus
trap, focus restore, Escape, background inertness and top-layer
stacking -- the last of which removed the hand-maintained z-index
ladder entirely. Escape is no longer six document handlers coordinated
by e.defaultPrevented with priority set by <script> order; layers now
register explicitly. The hover tooltip became a top-layer popover,
since no z-index can reach above a modal dialog.
Docked layout
An app bar across the window, the layers dock pinned left, the tool
dock (altitude rail + paste/network panels, one at a time) pinned
right. The docks OVERLAY the map rather than taking grid columns, and
#map always fills the area below the app bar. That is deliberate: as
grid columns, every dock toggle resized the map and Leaflet's
invalidateSize re-centred it, sliding the world ~141px sideways. Two
rounds of compensation code were written and both reverted; not
resizing the map removes the problem instead of correcting for it.
See docs/dock-map-anchoring.md before changing this.
The layers dock push-navigates between the category list and one
category's contents instead of opening a second column, so browsing
costs the map nothing and there is no half-empty pane.
Also fixed along the way
- #logoButton was a button with a title, a pointer cursor and no click
handler anywhere; it is a brand mark now.
- The altitude rail's width was reserved even while hidden, showing as
a dead black strip beside the map before any save was loaded.
- The empty state was ~700px of void with the drop zone marooned at
the bottom.
- The search field kept its query after committing a result.
- Delete in the selection bar looked identical to Copy.
- No layout media queries existed at all; there are now breakpoints at
1200/980/820/620px.
- Accessibility: combobox roles on the search field, inert on the
off-screen dock pane, aria-labels on icon-only buttons.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CONTRIBUTING gains map.css / ui.css / ui.js in the project layout (with the rule that new chrome reuses the primitives rather than restyling its own), the two new tools, and how to run the UI guards. dock-map-anchoring.md records why the docks overlay the map instead of taking grid columns. Two attempts at compensating for Leaflet re-centring the map on a dock toggle were written and reverted, the second one worse than the bug it fixed, and the geometry-in-headless testing that green-lit it was measuring the wrong thing. That is worth writing down: without it the obvious "improvement" is to turn the docks back into columns and walk straight into it again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The active-filter banner sat visibly right of the search field above it. Auditing every centred element across ten widths and four dock states turned up two independent causes. The map's overlay layer (hints, selection bar, filter banner) insetted by each dock separately, so it centred on the VISIBLE map rather than the window -- 109px out from the search field with just the layers dock open, and worse with a tool dock. Insetting symmetrically fixed the centre but then squeezed the layer narrower than a wide bar, at which point the auto margins collapsed and it drifted again. The layer now spans the full window exactly like #map and never changes size, so its centre is the window's centre unconditionally. A bar wider than the gap between the docks runs under one; the docks paint above it, so it is clipped rather than overlapping, and at any normal width there is room to spare. The editor toolbar was separately a quarter of the window out, from a leftover alignToolbar() that measured the search box and wrote an inline `left` in VIEWPORT coordinates. That was correct when the top bar was a row of floating islands over a full-window map; once the toolbar moved into an insetted overlay layer its containing block was no longer the viewport, so the value was wrong by exactly the layer's offset. CSS centring does the job now, so the function is gone. While in here: overlay children are centred by grid `justify-self` rather than `left: 50%` + a translate -- no percentage to resolve, and it lands on whole pixels so the text stays crisp. And the app bar's pill labels now drop at 1150px rather than 980px: the bar centres its search field by giving both side clusters an equal share of the leftover space, which only holds while each side's content fits its share, and with labels the right-hand cluster stopped fitting at about 1150. ui_behaviour.py gains a check that every centred element agrees with the window centre across dock states, plus one for the altitude rail's 4px track. Verified over 40 width/dock combinations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.