From 50d39965cf43c931a60cf857a2cf4e30f45bd358 Mon Sep 17 00:00:00 2001 From: Corey Ryan Dean Date: Tue, 26 May 2026 16:46:05 -0500 Subject: [PATCH] Loom: data loading + atlas boot surface (PR 2 of 4) Wires GUE's data-layer modules into Loom and replaces the skeleton's splash loop with a clickable zone atlas. Loom now opens the same kind of project GUE opens, reads the same .dat files through the same loaders, and shows every zone as a card the user can click. Includes added (same modules GUE pulls in, in the same order): RCEnet, Media, MediaImport, Projectiles, Language, Items, Inventories, Animations, Spells, Actors, Environment, Interface, ServerAreas, Packets, Logging. Deliberately omitted: - MediaDialogs.bb / CharacterEditorLoader.bb -- F-UI-tied editing surfaces, not needed for a read-only data layer. - ClientAreas.bb -- depends on GetFilename$, which lives inside GUE.bb itself (not in a shared module). ClientAreas loads the 3D zone mesh; we don't need that until PR #3, at which point I'll either extract GetFilename$ to a shared helper or define a Loom- side mesh loader. - F-UI -- still not pulled in. The atlas is custom-drawn through Theme.bb, hit-tested with raw MouseX/Y. F-UI shows up in PR #3 only for surfaces that need text input or native file dialogs. Atlas surface (src/Modules/Loom/Atlas.bb): - Grid of zone cards, each showing the zone name and portal / spawn / trigger counts (walked from the Area type's fixed-size arrays, same pattern the prior Loom-on-GUE atlas used). - Hover state lifts the card border from brass to arcane blue. - Click selects the zone; the selection round-trips as Handle(Area) so PR #3's world view can recover the typed reference via Object.Area(handle). - Top brand ribbon shows "LOOM / World Atlas" on the left and the project's leaf folder name centered. - Footer shows the zone count and an Esc-to-exit hint. - Empty-state copy when a project has no zones yet. - Selection toast in the bottom-left confirms which zone was clicked (placeholder feedback until PR #3 hands off to the world view). Boot flow (src/Loom.bb): 1. Bootstrap globals + ChangeDir to project root (unchanged from PR #1) 2. Open Blitz3D window + start log 3. Loom_DrawLoadingScreen("Loading project data...") 4. Run every Load* call in GUE's order; RuntimeError on failure (mirrors GUE.bb's expectation that the project's .dat files exist) 5. Atlas_Init() builds the tile list from `Each Area` 6. Per-frame: Atlas_RenderAndUpdate returns Handle(Area) on click 7. Esc exits cleanly All five engine targets (Server, Client, Project Manager, GUE, Loom) compile clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Loom.bb | 228 ++++++++++++++++++++++++++++--------- src/Modules/Loom/Atlas.bb | 229 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 404 insertions(+), 53 deletions(-) create mode 100644 src/Modules/Loom/Atlas.bb diff --git a/src/Loom.bb b/src/Loom.bb index fb006e32..cf878c8e 100644 --- a/src/Loom.bb +++ b/src/Loom.bb @@ -46,16 +46,41 @@ ChangeDir RootDir$ // ----------------------------------------------------------------------------- -// Includes -- minimum surface for the skeleton. +// Includes // -// PR #1 deliberately does NOT include the data modules (Items, Actors, -// Spells, etc.) or F-UI. The skeleton's only job is to prove the build -// pipeline and the Project Manager hook work. PR #2 will add Logging- -// adjacent data loaders. PR #3 brings in Blitz3D's 3D pipeline for the -// world view. +// Data layer: the same modules GUE includes for its data layer, MINUS the +// UI-tied ones (F-UI, MediaDialogs, CharacterEditorLoader). The loaders here +// just parse .dat files into the global type instances (ItemList, ActorList, +// SpellsList, Each Area, ...). Loom reads through these same in-memory +// instances so anything GUE can edit, Loom can see. +// +// Order matters: types must be declared before any code that uses them in +// later includes. We mirror GUE.bb's include order to stay in lockstep. // ----------------------------------------------------------------------------- +Include "Modules\RCEnet.bb" +Include "Modules\Media.bb" +Include "Modules\MediaImport.bb" +Include "Modules\Projectiles.bb" +Include "Modules\Language.bb" +Include "Modules\Items.bb" +Include "Modules\Inventories.bb" +Include "Modules\Animations.bb" +Include "Modules\Spells.bb" +Include "Modules\Actors.bb" +Include "Modules\Environment.bb" +Include "Modules\Interface.bb" +// NOTE: ClientAreas.bb deliberately omitted -- it depends on GetFilename$, +// which lives inside GUE.bb itself (not in a shared module). ClientAreas +// loads the 3D zone mesh; we don't need that for the atlas. PR #3 will +// pull it in via either extracting GetFilename$ to a shared helper or +// defining a Loom-side zone-mesh loader. +Include "Modules\ServerAreas.bb" +Include "Modules\Packets.bb" Include "Modules\Logging.bb" + +// Loom UI layer. Include "Modules\Loom\Theme.bb" +Include "Modules\Loom\Atlas.bb" // ----------------------------------------------------------------------------- @@ -88,22 +113,104 @@ WriteLog(LoomLog, "Resolution: " + Str(Loom_width) + "x" + Str(Loom_height)) // /. The leaf folder name is the project's display name. // ----------------------------------------------------------------------------- Local cwd$ = CurrentDir$() -Local projectName$ = LoomGetLeafDir(cwd$) +Global LoomProjectName$ = LoomGetLeafDir(cwd$) WriteLog(LoomLog, "Project root: " + cwd$) -WriteLog(LoomLog, "Project name: " + projectName$) +WriteLog(LoomLog, "Project name: " + LoomProjectName$) LoomTheme_Init() // ----------------------------------------------------------------------------- -// Splash screen loop. Runs until Esc. -// PR #2 replaces this with the atlas as the boot surface. +// Load project data. Same order GUE uses, same loaders, same in-memory +// representation. Loom never reads the .dat files directly -- it always +// goes through these loaders so the two editors can't drift apart in how +// they parse the files. +// +// Failure mode: if a required .dat is missing or unreadable, RuntimeError +// shows a Win32 dialog and exits. Mirrors GUE.bb's behavior; a half-loaded +// project would just confuse the user later. +// ----------------------------------------------------------------------------- +WriteLog(LoomLog, "** Loading project data **") +Loom_DrawLoadingScreen("Loading project data...") + +Loom_LoadStep("damage types", LoadDamageTypes("Data\Server Data\Damage.dat"), False) +Loom_LoadStep("attributes", LoadAttributes("Data\Server Data\Attributes.dat"), False) +Loom_LoadStep("factions", LoadFactions("Data\Server Data\Factions.dat"), True) +Loom_LoadStep("animations", LoadAnimSets("Data\Game Data\Animations.dat"), True) + +Global TotalProjectiles = LoadProjectiles("Data\Server Data\Projectiles.dat") +If TotalProjectiles = -1 Then RuntimeError("Loom could not open Data\Server Data\Projectiles.dat") +WriteLog(LoomLog, "Loaded " + Str(TotalProjectiles) + " projectiles") + +Global TotalItems = LoadItems("Data\Server Data\Items.dat") +If TotalItems = -1 Then RuntimeError("Loom could not open Data\Server Data\Items.dat") +WriteLog(LoomLog, "Loaded " + Str(TotalItems) + " items") + +Global TotalActors = LoadActors("Data\Server Data\Actors.dat") +If TotalActors = -1 Then RuntimeError("Loom could not open Data\Server Data\Actors.dat") +WriteLog(LoomLog, "Loaded " + Str(TotalActors) + " actors") + +Global TotalSpells = LoadSpells("Data\Server Data\Spells.dat") +If TotalSpells = -1 Then RuntimeError("Loom could not open Data\Server Data\Spells.dat") +WriteLog(LoomLog, "Loaded " + Str(TotalSpells) + " spells") + +// Server-side zones: every .dat in Data\Server Data\Areas\ is a zone. +Global TotalZones = 0 +Local zoneDir = ReadDir("Data\Server Data\Areas") +Local zoneFile$ = NextFile$(zoneDir) +While zoneFile$ <> "" + If FileType("Data\Server Data\Areas\" + zoneFile$) = 1 And Len(zoneFile$) > 4 + ServerLoadArea(Left$(zoneFile$, Len(zoneFile$) - 4)) + TotalZones = TotalZones + 1 + EndIf + zoneFile$ = NextFile$(zoneDir) +Wend +CloseDir(zoneDir) +WriteLog(LoomLog, "Loaded " + Str(TotalZones) + " zones") + +WriteLog(LoomLog, "** Data load complete **") + + +// ----------------------------------------------------------------------------- +// Build the atlas tile list now that Each Area is populated. +// ----------------------------------------------------------------------------- +Atlas_Init() + + // ----------------------------------------------------------------------------- -WriteLog(LoomLog, "** Splash loop running **") +// Boot surface: world atlas. Click a zone to "select" it -- this PR just +// logs the selection and shows an acknowledgement overlay for one frame +// to confirm the interaction is plumbed; PR #3 will hand off to the world +// view that loads the zone's mesh and entities in 3D. +// +// Esc exits. +// ----------------------------------------------------------------------------- +WriteLog(LoomLog, "** Atlas loop running **") + +Global LoomSelectedZone = 0 // Handle(Area); set by Atlas_RenderAndUpdate Repeat Cls - LoomRenderSplash(Loom_width, Loom_height, projectName$) + + Local pickedHandle = Atlas_RenderAndUpdate(Loom_width, Loom_height, LoomProjectName$) + If pickedHandle <> 0 + LoomSelectedZone = pickedHandle + Local pickedArea.Area = Object.Area(LoomSelectedZone) + If pickedArea <> Null + WriteLog(LoomLog, "Atlas: selected zone '" + pickedArea\Name$ + "' (handle " + Str(LoomSelectedZone) + ")") + EndIf + EndIf + + // Selected-zone toast in the bottom-left corner. Persists until another + // selection or until exit -- gives the user feedback that the click was + // received while PR #3's world view is still pending. + If LoomSelectedZone <> 0 + Local sel.Area = Object.Area(LoomSelectedZone) + If sel <> Null + Loom_DrawSelectionToast(Loom_width, Loom_height, sel\Name$) + EndIf + EndIf + Flip Until KeyHit(1) @@ -113,48 +220,63 @@ End // ============================================================================= -// LoomRenderSplash -- paint the alpha splash surface. -// -// Layout: -// - Full-screen vertical gradient stone_900 -> stone_950 -// - Centered "LOOM" title in parchment -// - "WORLD EDITOR" subtitle in brass, spaced -// - Brass divider rule -// - Project context line -// - Footer instruction +// Loom_LoadStep -- check the return value of a Load* call, log it, RuntimeError +// on failure. isMinusOneFailure: True if the loader returns -1 on failure +// (LoadFactions, LoadAnimSets), False if it returns False (LoadDamageTypes, +// LoadAttributes). Mirrors the inconsistent return-value conventions of GUE's +// own loaders -- we don't reshape those here, just route them. +// ============================================================================= +Function Loom_LoadStep(stepName$, result, isMinusOneFailure) + Local failed = False + If isMinusOneFailure = True + If result = -1 Then failed = True + Else + If result = False Then failed = True + EndIf + + If failed = True + WriteLog(LoomLog, "LOAD FAILED: " + stepName$) + RuntimeError("Loom could not load " + stepName$ + ". Make sure the project's Data folder is intact and try again.") + EndIf + + WriteLog(LoomLog, "Loaded " + stepName$) +End Function + + // ============================================================================= -Function LoomRenderSplash(sw, sh, projectName$) - // Background gradient (BlitzForge does not support line continuation, - // so these calls are intentionally long single lines.) - LoomGradientV(0, 0, sw, sh, LOOM_STONE_900_R, LOOM_STONE_900_G, LOOM_STONE_900_B, LOOM_STONE_950_R, LOOM_STONE_950_G, LOOM_STONE_950_B) - - Local cx = sw / 2 - Local cy = sh / 2 - - // Title -- "LOOM" centered, drawn twice with a 1px offset to fake bolder - // weight on top of the Blitz default font. Real display fonts arrive in - // a later PR. - LoomTextCentered(cx, cy - 90, "LOOM", LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) - LoomTextCentered(cx + 1, cy - 90, "LOOM", LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) - - // Subtitle - LoomTextCentered(cx, cy - 64, "W O R L D E D I T O R", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) - - // Brass divider (triple rule for an ornamented bar) - LoomHRule(cx - 180, cy - 40, 360, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) - LoomHRule(cx - 180, cy - 39, 360, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) - LoomHRule(cx - 180, cy - 38, 360, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) - - // Project context - LoomTextCentered(cx, cy - 16, "Alpha for " + projectName$, LOOM_STONE_200_R, LOOM_STONE_200_G, LOOM_STONE_200_B) - LoomTextCentered(cx, cy + 2, "Realm Crafter Community Edition " + rcceVersion$, LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) - - // Skeleton-stage notice (will be removed in PR #2 when the atlas becomes - // the boot surface). - LoomTextCentered(cx, cy + 60, "skeleton build -- atlas, world view, and composer arrive in subsequent PRs", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) - - // Footer - LoomTextCentered(cx, sh - 40, "Esc to exit", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) +// Loom_DrawLoadingScreen -- show a single-frame loading message while the +// data loaders run. Called once before the slow Load* calls; the actual +// progress isn't streamed because the loads are fast enough on modern disks +// that an animated splash would just flicker. +// ============================================================================= +Function Loom_DrawLoadingScreen(msg$) + Cls + LoomGradientV(0, 0, GraphicsWidth(), GraphicsHeight(), LOOM_STONE_900_R, LOOM_STONE_900_G, LOOM_STONE_900_B, LOOM_STONE_950_R, LOOM_STONE_950_G, LOOM_STONE_950_B) + Local cx = GraphicsWidth() / 2 + Local cy = GraphicsHeight() / 2 + LoomTextCentered(cx, cy - 10, "LOOM", LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + LoomTextCentered(cx, cy + 10, msg$, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + Flip +End Function + + +// ============================================================================= +// Loom_DrawSelectionToast -- bottom-left transient banner confirming which +// zone the user just clicked. Replaced in PR #3 by an actual world-view +// hand-off; here it's the visible feedback that the atlas click was received. +// ============================================================================= +Function Loom_DrawSelectionToast(sw, sh, zoneName$) + Local toastW = 360 + Local toastH = 56 + Local toastX = 20 + Local toastY = sh - ATLAS_BOT_RIBBON - toastH - 12 + + LoomFill(toastX, toastY, toastW, toastH, LOOM_STONE_800_R, LOOM_STONE_800_G, LOOM_STONE_800_B) + LoomBorder(toastX, toastY, toastW, toastH, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + LoomBorder(toastX + 1, toastY + 1, toastW - 2, toastH - 2, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + + LoomText(toastX + 12, toastY + 10, "Selected", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(toastX + 12, toastY + 28, zoneName$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) End Function diff --git a/src/Modules/Loom/Atlas.bb b/src/Modules/Loom/Atlas.bb new file mode 100644 index 00000000..053ef9f4 --- /dev/null +++ b/src/Modules/Loom/Atlas.bb @@ -0,0 +1,229 @@ +// ============================================================================= +// Loom/Atlas.bb -- world atlas (zone picker) boot surface +// ============================================================================= +// +// The atlas is the first surface the user sees after Loom finishes loading +// project data. It lists every zone in the project as a clickable card laid +// out in a grid. Clicking a card "selects" the zone -- in this PR that just +// records the selection and exits the atlas; PR #3 will hand off to the +// world-view surface that renders the chosen zone in 3D. +// +// Why custom-draw rather than FUI_ListBox: +// The Loom design treats zone selection as a spatial overview, not a +// dropdown. Cards with per-zone stats (portal / spawn / trigger counts) +// beat a textual list of names, and the dark-fantasy aesthetic requires +// colors and ornament F-UI can't render. We paint everything through +// Theme.bb's primitives and hit-test the mouse ourselves -- a few dozen +// rectangles, trivial cost per frame. +// +// Public API: +// Atlas_Init() -- called once after data is loaded +// Atlas_RenderAndUpdate(sw, sh, project$) -> selectedHandle +// -- per-frame; returns the +// Handle(Area) the user clicked +// this frame (0 if no click) +// +// Internal state -- the tile rectangles built once at Init from Each Area +// and reused every frame for both render and hit-test. +// ============================================================================= + + +// Layout constants (all in pixels). +Const ATLAS_TILE_W = 280 +Const ATLAS_TILE_H = 90 +Const ATLAS_GAP = 16 +Const ATLAS_TOP_RIBBON = 56 // brand strip at the very top +Const ATLAS_BOT_RIBBON = 36 // footer strip at the very bottom +Const ATLAS_SECTION_PAD = 32 // padding around the tile grid + + +// One entry per zone -- pre-computed at Init so render + hit-test don't +// recompute per frame. The Area handle round-trips to the caller as the +// "selected zone" identifier (matching the convention GUE_JumpToEntity used +// for zones in the previous round). +Type AtlasTile + Field AreaHandle + Field Name$ + Field Portals + Field Spawns + Field Triggers + Field X, Y // top-left corner, computed each frame from sw/sh +End Type +Global Atlas_FirstTile.AtlasTile = Null // marker: any tiles built? + +// Diagnostic state -- not load-bearing for the UI but useful for the log. +Global Atlas_ZoneCount = 0 + + +// ============================================================================= +// Atlas_Init -- walk every Area, build one tile per zone with its summary +// counts. Safe to call multiple times (clears previous tiles first). +// ============================================================================= +Function Atlas_Init() + // Clear any previous tiles (in case Init is called after data reload). + For old.AtlasTile = Each AtlasTile + Delete old + Next + Atlas_FirstTile = Null + Atlas_ZoneCount = 0 + + // One tile per Area. Counts walk the Area's fixed-size arrays. + For Ar.Area = Each Area + Local t.AtlasTile = New AtlasTile + t\AreaHandle = Handle(Ar) + t\Name$ = Ar\Name$ + t\Portals = Atlas_CountPortals(Ar) + t\Spawns = Atlas_CountSpawns(Ar) + t\Triggers = Atlas_CountTriggers(Ar) + If Atlas_FirstTile = Null Then Atlas_FirstTile = t + Atlas_ZoneCount = Atlas_ZoneCount + 1 + Next + + WriteLog(LoomLog, "Atlas: indexed " + Str(Atlas_ZoneCount) + " zones") +End Function + + +// Walk the Area's three reference arrays to produce summary counts. Same +// pattern (and same magic-number bounds) as Area's fixed-size storage. +Function Atlas_CountPortals(Ar.Area) + Local n = 0 + Local i = 0 + For i = 0 To 99 + If Ar\PortalName$[i] <> "" Then n = n + 1 + Next + Return n +End Function + +Function Atlas_CountSpawns(Ar.Area) + Local n = 0 + Local i = 0 + For i = 0 To 999 + If Ar\SpawnActor[i] > 0 Then n = n + 1 + Next + Return n +End Function + +Function Atlas_CountTriggers(Ar.Area) + Local n = 0 + Local i = 0 + For i = 0 To 149 + If Ar\TriggerScript$[i] <> "" Then n = n + 1 + Next + Return n +End Function + + +// ============================================================================= +// Atlas_RenderAndUpdate -- per-frame entry point. Paints the atlas surface +// (top brand strip + tile grid + footer hint), tracks mouse hover for the +// tiles, and returns Handle(Area) if the user clicked a tile this frame +// (0 otherwise). +// ============================================================================= +Function Atlas_RenderAndUpdate(sw, sh, project$) + Local mx = MouseX() + Local my = MouseY() + Local clicked = MouseHit(1) + Local selectedHandle = 0 + + // -- Background gradient ------------------------------------------------ + LoomGradientV(0, 0, sw, sh, LOOM_STONE_900_R, LOOM_STONE_900_G, LOOM_STONE_900_B, LOOM_STONE_950_R, LOOM_STONE_950_G, LOOM_STONE_950_B) + + // -- Top brand strip ---------------------------------------------------- + Atlas_DrawTopRibbon(sw, project$) + + // -- Footer hint -------------------------------------------------------- + Atlas_DrawFooter(sw, sh) + + // -- Tile grid ---------------------------------------------------------- + // Compute columns that fit. Reserve ATLAS_SECTION_PAD on either side. + Local gridX = ATLAS_SECTION_PAD + Local gridY = ATLAS_TOP_RIBBON + ATLAS_SECTION_PAD + Local gridW = sw - (ATLAS_SECTION_PAD * 2) + Local cols = (gridW + ATLAS_GAP) / (ATLAS_TILE_W + ATLAS_GAP) + If cols < 1 Then cols = 1 + + // Empty-state message + If Atlas_FirstTile = Null + LoomTextCentered(sw / 2, sh / 2, "No zones found in this project.", LOOM_STONE_200_R, LOOM_STONE_200_G, LOOM_STONE_200_B) + LoomTextCentered(sw / 2, sh / 2 + 20, "Create a zone in GUE first, then come back to Loom.", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) + Return 0 + EndIf + + // Lay out + render each tile + Local col = 0 + Local row = 0 + For t.AtlasTile = Each AtlasTile + t\X = gridX + col * (ATLAS_TILE_W + ATLAS_GAP) + t\Y = gridY + row * (ATLAS_TILE_H + ATLAS_GAP) + + Local hovered = (mx >= t\X And mx < t\X + ATLAS_TILE_W And my >= t\Y And my < t\Y + ATLAS_TILE_H) + Atlas_DrawTile(t, hovered) + + If hovered And clicked Then selectedHandle = t\AreaHandle + + col = col + 1 + If col >= cols + col = 0 + row = row + 1 + EndIf + Next + + Return selectedHandle +End Function + + +// Paint one zone card. +Function Atlas_DrawTile(t.AtlasTile, hovered) + // Card fill -- darker stone, with subtle border. Hover lifts to arcane. + LoomFill(t\X, t\Y, ATLAS_TILE_W, ATLAS_TILE_H, LOOM_STONE_800_R, LOOM_STONE_800_G, LOOM_STONE_800_B) + + If hovered = True + LoomBorder(t\X, t\Y, ATLAS_TILE_W, ATLAS_TILE_H, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + LoomBorder(t\X + 1, t\Y + 1, ATLAS_TILE_W - 2, ATLAS_TILE_H - 2, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + Else + LoomBorder(t\X, t\Y, ATLAS_TILE_W, ATLAS_TILE_H, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + EndIf + + // Top brass accent line -- mimics ornamented panel headers in the design. + LoomHRule(t\X + 12, t\Y + 8, ATLAS_TILE_W - 24, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + + // Zone name + LoomText(t\X + 12, t\Y + 16, t\Name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + // Stats row -- portals / spawns / triggers, brass labels with parchment counts + Local statsY = t\Y + 50 + LoomText(t\X + 12, statsY, "Portals", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(t\X + 12, statsY + 16, Str(t\Portals), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + LoomText(t\X + 100, statsY, "Spawns", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(t\X + 100, statsY + 16, Str(t\Spawns), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + LoomText(t\X + 188, statsY, "Triggers", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(t\X + 188, statsY + 16, Str(t\Triggers), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +// Top brand strip: "LOOM" mark on the left, project name centered, count + Esc hint on the right. +Function Atlas_DrawTopRibbon(sw, project$) + LoomFill(0, 0, sw, ATLAS_TOP_RIBBON, LOOM_STONE_850_R, LOOM_STONE_850_G, LOOM_STONE_850_B) + LoomHRule(0, ATLAS_TOP_RIBBON - 1, sw, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + LoomHRule(0, ATLAS_TOP_RIBBON, sw, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomHRule(0, ATLAS_TOP_RIBBON + 1, sw, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + + LoomText(20, 18, "LOOM", LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + LoomText(20, 32, "World Atlas", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + + LoomTextCentered(sw / 2, 22, project$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +Function Atlas_DrawFooter(sw, sh) + Local y = sh - ATLAS_BOT_RIBBON + LoomFill(0, y, sw, ATLAS_BOT_RIBBON, LOOM_STONE_850_R, LOOM_STONE_850_G, LOOM_STONE_850_B) + LoomHRule(0, y, sw, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + + LoomText(20, y + 10, Str(Atlas_ZoneCount) + " zones ยท click a zone to inspect", LOOM_STONE_200_R, LOOM_STONE_200_G, LOOM_STONE_200_B) + + // Right-aligned: Esc hint + LoomText(sw - 20, y + 10, "Esc to exit", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B, 2) +End Function