From aba0d594f8293c00b2f7fb0ac13be3d735e0d55d Mon Sep 17 00:00:00 2001 From: Corey Ryan Dean Date: Tue, 26 May 2026 19:13:49 -0500 Subject: [PATCH] Loom: entity browser + thread chips (alpha pivot) Replaces the zone-only browse model from #293-#295. The previous alpha shipped a view of one corner of zone metadata (waypoints / spawns / triggers / portals) and skipped everything that actually makes a project interesting -- the actors, items, spells, factions. This pivot surfaces all of it through the design's actual centerpiece: "every reference is a clickable thread." What the alpha now does: Browser: everything-grid with a category tab bar at the top. Categories: Actors / Items / Spells / Zones / Factions / Animation Sets. Each card paints kind-specific summary content: actor : Race [Class] + faction name + XP multiplier item : name + type label + value spell : name + recharge + script binding (if any) zone : name + portal/spawn/trigger counts faction : name + computed member count animset : name + clip count + computed "used by" count Composer: right-side property panel that paints whatever's focused. Per-kind detail pages with the entity's full read-only field set. Each composer page also includes a "Threads" section with chips for every reference the entity makes: actor -> faction, M anim set, F anim set zone -> per-portal chips to the target zone (when the target resolves; broken portals render in danger red) faction -> chip per member actor (computed: every actor whose DefaultFaction matches this index) animset -> chip per actor using this anim set (computed: M or F binding) item -> no entity-to-entity references in rcce2's data model; composer shows the script binding as text spell -> same; script + restriction strings Thread chips (Threads.bb): clickable rounded rects with kind icon + target name + arrow. Click pushes current focus onto a back stack and refocuses on the target. Broken references render with a red border and danger-colored "(broken kind #N)" text. Back stack: Esc walks back through the navigation chain. Hero flow: Goblin Shaman -> [Faction: Forest Tribe] -> Forest Tribe -> [Member: Goblin Scout] -> Goblin Scout -> Esc -> Forest Tribe -> Esc -> Goblin Shaman -> Esc -> closes composer back to browser Footer hint shows "Esc walks back -- N in trail" when the stack is non-empty; switches to "Esc returns to browser" when empty. Architecture: src/Loom.bb Data loading (unchanged from #293). Main loop has a single Browser_RenderAndUpdate + Composer_RenderAndUpdate per frame. Esc consumes one of: pop back stack | close composer | exit Loom. src/Modules/Loom/Threads.bb Focus state (Loom_FocusKind$ / Loom_FocusID), back stack (Loom_BackStack BBList of LoomFocusEntry), name resolution per kind, and Threads_RenderChip primitive that all surfaces use. src/Modules/Loom/Browser.bb Category tab bar with brass underline for the active tab, paginated card grid that auto-fits the window width, per-kind card body renderers. src/Modules/Loom/Composer.bb Per-kind detail page renderers, row+chip layout helpers, boolean / enum / float formatters, zone-name -> Handle resolver for portal threads. Read-only throughout. Editing is a beta concern -- it needs save / dirty tracking that's its own design surface. All five engine targets compile clean. Loom.exe grew from ~2.0 MB (skeleton) to ~2.4 MB (with data layer + browser + composer + threads). About 1,200 lines of Loom-specific code total. Supersedes the closed #293 (atlas), #294 (zone map), and #295 (zone composer). Branches off develop on top of the merged #292 (skeleton + PM launcher). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Loom.bb | 258 ++++++++++++------- src/Modules/Loom/Browser.bb | 470 +++++++++++++++++++++++++++++++++++ src/Modules/Loom/Composer.bb | 463 ++++++++++++++++++++++++++++++++++ src/Modules/Loom/Threads.bb | 273 ++++++++++++++++++++ 4 files changed, 1375 insertions(+), 89 deletions(-) create mode 100644 src/Modules/Loom/Browser.bb create mode 100644 src/Modules/Loom/Composer.bb create mode 100644 src/Modules/Loom/Threads.bb diff --git a/src/Loom.bb b/src/Loom.bb index fb006e32..995ed20c 100644 --- a/src/Loom.bb +++ b/src/Loom.bb @@ -3,40 +3,33 @@ // ============================================================================= // // A drop-in alternative to GUE, sharing the on-disk data formats but with -// a fresh UI built around the Loom design concept (see -// .claude/skills/loom-design-brief/ and the prototype handoff bundle). +// a fresh UI built around the Loom design concept: every entity is browsable, +// every reference between entities is a clickable thread. // -// Architecture overview (the multi-PR roadmap; this commit ships only #1): +// Surface model: +// BROWSER everything-grid by category (Actors / Items / Spells / +// Zones / Factions / Animation Sets). Click a card to +// focus the entity in the COMPOSER. // -// #1 Skeleton + theme + Project Manager launcher -// Loom.exe compiles, Project Manager launches it, -// shows a themed splash, exits cleanly. THIS COMMIT. +// COMPOSER right-side property panel for the focused entity. +// Reference fields render as thread chips (Threads.bb); +// clicking a chip jumps focus and pushes a back-stack +// entry. Esc pops the stack (or, if empty, closes the +// composer back to the browser). // -// #2 Data loading + atlas -// Loom uses GUE's existing data modules (Items.bb, Actors.bb, -// Spells.bb, ServerAreas.bb, ...) via Include. After load, -// the atlas surface lists every zone in the project. +// Esc behavior: +// composer focused, back stack non-empty -> pop one step back +// composer focused, back stack empty -> close composer +// browser only (nothing focused) -> exit Loom // -// #3 World view -// Picking a zone in the atlas renders it in a 3D viewport -// using Blitz3D's engine (the same engine GUE's Zones tab uses). -// Click an entity to select it. -// -// #4 Composer -// Right-side property panel that paints the focused entity's -// data (faction, level, mesh, equipped items) using Loom theme -// primitives. Read-only for the alpha. -// -// Design intent for the alpha as a whole: "Loom can open my existing -// Realm Crafter project and let me look at my world through a different -// lens." Editing comes in beta. +// Read-only in this alpha. Editing is a beta concern (needs save/dirty +// tracking that's its own design surface). // ============================================================================= // ----------------------------------------------------------------------------- -// Bootstrap globals (mirrors GUE.bb's startup so the relative paths work -// identically -- both binaries live in bin/ and are launched with CWD set to -// /Data/). +// Bootstrap globals (mirrors GUE.bb so paths and log placement match -- both +// binaries live in bin/ and are launched from PM with CWD set to /Data). // ----------------------------------------------------------------------------- Global rcceVersion$ = "2.0.0" Global componentName$ = "loom" @@ -46,16 +39,40 @@ ChangeDir RootDir$ // ----------------------------------------------------------------------------- -// Includes -- minimum surface for the skeleton. +// Includes +// +// Data layer: same modules GUE pulls in for the data layer, minus the +// UI-tied ones (F-UI, MediaDialogs, CharacterEditorLoader, ClientAreas). +// The loaders here parse .dat files into the global type instances; Loom +// reads through those same instances so the two editors can't drift apart +// in how they parse the files. // -// 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. +// Order matters for Type declarations -- mirror GUE.bb's order. // ----------------------------------------------------------------------------- +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" +// ClientAreas.bb deliberately omitted -- depends on GetFilename$ which +// lives inside GUE.bb. We don't need 3D zone meshes for the alpha; the +// composer renders zone metadata as text + portal chips. +Include "Modules\ServerAreas.bb" +Include "Modules\Packets.bb" Include "Modules\Logging.bb" + +// Loom UI layer. Include "Modules\Loom\Theme.bb" +Include "Modules\Loom\Threads.bb" +Include "Modules\Loom\Browser.bb" +Include "Modules\Loom\Composer.bb" // ----------------------------------------------------------------------------- @@ -74,8 +91,7 @@ AppTitle("Loom -- World Editor (Alpha) -- Realm Crafter " + rcceVersion$) // ----------------------------------------------------------------------------- -// Log -- written to Data\Logs\Loom Log.txt (relative to project root, the -// same place GUE writes its log). +// Log -- Data\Logs\Loom Log.txt (relative to project root, next to GUE's log). // ----------------------------------------------------------------------------- Global LoomLog = StartLog("Loom Log", False) WriteLog(LoomLog, "** Loom startup begins **", True, True) @@ -83,29 +99,105 @@ WriteLog(LoomLog, "Resolution: " + Str(Loom_width) + "x" + Str(Loom_height)) // ----------------------------------------------------------------------------- -// Resolve project name from the working directory. When PM launches us, CWD -// has been set to /Data/ and then ChangeDir "..\" walked us up to -// /. The leaf folder name is the project's display name. +// Resolve project name from the working directory leaf. // ----------------------------------------------------------------------------- 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. Failures RuntimeError with a Win32 dialog -- 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") + +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 **") + + +// ----------------------------------------------------------------------------- +// Initialize Loom UI state. +// ----------------------------------------------------------------------------- +Threads_Init() +Browser_Init() + + +// ----------------------------------------------------------------------------- +// Main loop. Single surface that paints the browser, then layers the +// composer on top when something's focused. Click flows: +// +// browser card click -> Threads_Focus (no back-stack push) +// composer chip click -> Threads_Jump (back-stack push) +// +// Esc consumes one of: +// 1. Threads_Back if back stack non-empty +// 2. Close composer if focus exists but stack empty +// 3. Exit Loom otherwise // ----------------------------------------------------------------------------- -WriteLog(LoomLog, "** Splash loop running **") +WriteLog(LoomLog, "** Main loop running **") Repeat Cls - LoomRenderSplash(Loom_width, Loom_height, projectName$) + + Browser_RenderAndUpdate(Loom_width, Loom_height, LoomProjectName$) + Composer_RenderAndUpdate(Loom_width, Loom_height) + + If KeyHit(1) // Esc + If Threads_Back() = False + If Loom_FocusKind$ <> "" + // Close composer back to plain browser. + Threads_Focus("", 0) + Threads_ClearStack() + WriteLog(LoomLog, "Esc: closed composer") + Else + // Nothing left to close -- exit Loom. + Exit + EndIf + EndIf + EndIf + Flip -Until KeyHit(1) +Until False WriteLog(LoomLog, "** Loom shutdown **") CloseAllLogs() @@ -113,59 +205,47 @@ 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 -- route the inconsistent loader return-value conventions +// (some return -1 on failure, some return False) through a single check. // ============================================================================= -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) +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 + + +// ============================================================================= +// Loom_DrawLoadingScreen -- single-frame loading message while the data +// loaders run. The loads are fast enough on modern disks that an animated +// progress 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 // ============================================================================= -// LoomGetLeafDir -- return the leaf folder name from a directory path. -// E.g. "C:\rcce2\projects\Embergloom" -> "Embergloom". -// Falls back to the whole path if no separator is found. +// LoomGetLeafDir -- leaf folder name from a directory path. // ============================================================================= Function LoomGetLeafDir$(path$) Local trimmed$ = path$ - // Strip trailing slashes / backslashes so the leaf isn't an empty string. While Len(trimmed$) > 1 And (Right$(trimmed$, 1) = "\" Or Right$(trimmed$, 1) = "/") trimmed$ = Left$(trimmed$, Len(trimmed$) - 1) Wend diff --git a/src/Modules/Loom/Browser.bb b/src/Modules/Loom/Browser.bb new file mode 100644 index 00000000..3aff5280 --- /dev/null +++ b/src/Modules/Loom/Browser.bb @@ -0,0 +1,470 @@ +// ============================================================================= +// Loom/Browser.bb -- everything-browser (entity picker grid by category) +// ============================================================================= +// +// The boot surface. Replaces the zone-only atlas: every entity type the +// project contains gets its own category, and each category renders as a +// grid of clickable cards. Clicking a card focuses the entity in the +// composer (which slides in from the right via Composer.bb). +// +// Categories (in tab order): +// actor / item / spell / zone / faction / animset +// +// Per-category card content (kept compact so 3 cards fit per row at 1280): +// actor : "Race [Class]" + faction name + level +// item : name + type label (Weapon / Armour / Potion / etc.) + value +// spell : name + "Recharge Ns" subtitle +// zone : name + portal/spawn/trigger counts (same as old zone atlas) +// faction : name + member count (computed: actors whose DefaultFaction +// equals this faction index) +// animset : name + animation-clip count +// +// Public API: +// Browser_Init() +// One-time setup. Picks "actor" as the initial category and pre-builds +// the category-bar tab rects (recomputed each frame from sw, kept here +// so the constants are defined in one place). +// +// Browser_RenderAndUpdate(sw, sh, project$) -> True if a card was clicked +// Per-frame paint + hit-test. Returns True when the user clicked a +// card; the click set Loom_FocusKind$/Loom_FocusID via Threads_Focus +// already, so the caller just needs to switch into compose mode. +// +// Browser_Categories[] -- the ordered list of category kinds, exposed +// so the chrome can iterate. +// ============================================================================= + + +// Layout +Const BR_TOP_RIBBON = 56 +Const BR_TAB_BAR_H = 36 +Const BR_BOT_RIBBON = 36 +Const BR_SECTION_PAD = 28 +Const BR_CARD_W = 300 +Const BR_CARD_H = 96 +Const BR_CARD_GAP = 14 + + +// Category state. Initialized to "actor" because actors are usually the +// richest content in a project and the most useful starting point. +Global Browser_Category$ = "actor" + + +// Category descriptors -- title shown in the tab bar + the kind id used +// across the rest of the Loom code. +Type BrowserCategory + Field Kind$ + Field Title$ +End Type +Global Browser_FirstCategory.BrowserCategory = Null + + +// Tab-rect bookkeeping (mutated each frame as a side-effect of rendering +// the tab bar; the per-frame mouse hit-test reads it). +Type BrowserTabRect + Field Kind$ + Field X, Y, W, H +End Type +Global Browser_FirstTabRect.BrowserTabRect = Null + + +// Card-rect bookkeeping (same pattern) +Type BrowserCardRect + Field Kind$ + Field RefID + Field X, Y, W, H +End Type +Global Browser_FirstCardRect.BrowserCardRect = Null + + +// ============================================================================= +// Browser_Init +// ============================================================================= +Function Browser_Init() + // Build the ordered category list once. + Browser_AddCategory("actor", "Actors") + Browser_AddCategory("item", "Items") + Browser_AddCategory("spell", "Spells") + Browser_AddCategory("zone", "Zones") + Browser_AddCategory("faction", "Factions") + Browser_AddCategory("animset", "Animation Sets") +End Function + + +Function Browser_AddCategory(kind$, title$) + Local c.BrowserCategory = New BrowserCategory + c\Kind$ = kind$ + c\Title$ = title$ + If Browser_FirstCategory = Null Then Browser_FirstCategory = c +End Function + + +// ============================================================================= +// Browser_RenderAndUpdate -- per-frame paint + hit-test. +// ============================================================================= +Function Browser_RenderAndUpdate(sw, sh, project$) + Local mx = MouseX() + Local my = MouseY() + Local clicked = MouseHit(1) + + // 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) + + // Chrome + Browser_DrawTopRibbon(sw, project$) + Browser_DrawTabBar(sw, mx, my, clicked) + Browser_DrawFooter(sw, sh) + + // Card grid + Local clickedACard = Browser_DrawCardGrid(sw, sh, mx, my, clicked) + Return clickedACard +End Function + + +// ----------------------------------------------------------------------------- +// Top brand strip +// ----------------------------------------------------------------------------- +Function Browser_DrawTopRibbon(sw, project$) + LoomFill(0, 0, sw, BR_TOP_RIBBON, LOOM_STONE_850_R, LOOM_STONE_850_G, LOOM_STONE_850_B) + LoomHRule(0, BR_TOP_RIBBON - 1, sw, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + LoomHRule(0, BR_TOP_RIBBON, sw, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomHRule(0, BR_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, "Browser", 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 + + +// ----------------------------------------------------------------------------- +// Category tab bar -- one tab per kind, active tab gets a brass underline. +// +// Mutates Browser_FirstTabRect side-table for the hit-test. Re-allocates +// the list each frame to keep the API stateless from the caller's POV. +// ----------------------------------------------------------------------------- +Function Browser_DrawTabBar(sw, mx, my, clicked) + // Clear last frame's tab rects + For old.BrowserTabRect = Each BrowserTabRect + Delete old + Next + Browser_FirstTabRect = Null + + Local y = BR_TOP_RIBBON + Local h = BR_TAB_BAR_H + LoomFill(0, y, sw, h, LOOM_STONE_800_R, LOOM_STONE_800_G, LOOM_STONE_800_B) + LoomHRule(0, y + h, sw, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + + Local x = 20 + For c.BrowserCategory = Each BrowserCategory + // Pad each tab label so the click target is generous. + Local w = StringWidth(c\Title$) + 40 + Local active = (c\Kind$ = Browser_Category$) + Local hovered = (mx >= x And mx < x + w And my >= y And my < y + h) + + // Hover background + If hovered = True + LoomFill(x, y, w, h, LOOM_STONE_700_R, LOOM_STONE_700_G, LOOM_STONE_700_B) + EndIf + + // Tab label color + If active = True + LoomText(x + 20, y + 11, c\Title$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + // Brass underline for the active tab + LoomFill(x + 8, y + h - 3, w - 16, 3, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + Else + LoomText(x + 20, y + 11, c\Title$, LOOM_STONE_200_R, LOOM_STONE_200_G, LOOM_STONE_200_B) + EndIf + + // Stash the rect for hit-testing + Local tr.BrowserTabRect = New BrowserTabRect + tr\Kind$ = c\Kind$ + tr\X = x : tr\Y = y : tr\W = w : tr\H = h + If Browser_FirstTabRect = Null Then Browser_FirstTabRect = tr + + // Hit-test + If hovered And clicked + Browser_Category$ = c\Kind$ + WriteLog(LoomLog, "Browser: category -> " + c\Kind$) + EndIf + + x = x + w + 6 + Next +End Function + + +// ----------------------------------------------------------------------------- +// Card grid -- one card per entity in the current category. Mutates +// Browser_FirstCardRect for the hit-test. Returns True if a card was +// clicked this frame (and as a side effect Threads_Focus has been called). +// ----------------------------------------------------------------------------- +Function Browser_DrawCardGrid(sw, sh, mx, my, clicked) + // Clear last frame's card rects + For old.BrowserCardRect = Each BrowserCardRect + Delete old + Next + Browser_FirstCardRect = Null + + Local gridX = BR_SECTION_PAD + Local gridY = BR_TOP_RIBBON + BR_TAB_BAR_H + BR_SECTION_PAD + Local gridW = sw - (BR_SECTION_PAD * 2) + Local cols = (gridW + BR_CARD_GAP) / (BR_CARD_W + BR_CARD_GAP) + If cols < 1 Then cols = 1 + + Local col = 0 + Local row = 0 + Local count = 0 + Local clickedACard = False + + // Iterate the in-memory store for the current category and render one + // card per entity. We don't precompute the list because data could + // change between frames (no actual edit yet, but future-proofing). + + If Browser_Category$ = "actor" + For Ac.Actor = Each Actor + Local cx1 = gridX + col * (BR_CARD_W + BR_CARD_GAP) + Local cy1 = gridY + row * (BR_CARD_H + BR_CARD_GAP) + If cy1 + BR_CARD_H < sh - BR_BOT_RIBBON + Local h1 = Browser_DrawCardChrome("actor", Ac\ID, cx1, cy1, mx, my, clicked) + Browser_DrawActorCardBody(Ac, cx1, cy1) + If h1 Then clickedACard = True + EndIf + count = count + 1 : col = col + 1 + If col >= cols Then col = 0 : row = row + 1 + Next + Else If Browser_Category$ = "item" + For It.Item = Each Item + Local cx2 = gridX + col * (BR_CARD_W + BR_CARD_GAP) + Local cy2 = gridY + row * (BR_CARD_H + BR_CARD_GAP) + If cy2 + BR_CARD_H < sh - BR_BOT_RIBBON + Local h2 = Browser_DrawCardChrome("item", It\ID, cx2, cy2, mx, my, clicked) + Browser_DrawItemCardBody(It, cx2, cy2) + If h2 Then clickedACard = True + EndIf + count = count + 1 : col = col + 1 + If col >= cols Then col = 0 : row = row + 1 + Next + Else If Browser_Category$ = "spell" + For Sp.Spell = Each Spell + Local cx3 = gridX + col * (BR_CARD_W + BR_CARD_GAP) + Local cy3 = gridY + row * (BR_CARD_H + BR_CARD_GAP) + If cy3 + BR_CARD_H < sh - BR_BOT_RIBBON + Local h3 = Browser_DrawCardChrome("spell", Sp\ID, cx3, cy3, mx, my, clicked) + Browser_DrawSpellCardBody(Sp, cx3, cy3) + If h3 Then clickedACard = True + EndIf + count = count + 1 : col = col + 1 + If col >= cols Then col = 0 : row = row + 1 + Next + Else If Browser_Category$ = "zone" + For Ar.Area = Each Area + Local cx4 = gridX + col * (BR_CARD_W + BR_CARD_GAP) + Local cy4 = gridY + row * (BR_CARD_H + BR_CARD_GAP) + If cy4 + BR_CARD_H < sh - BR_BOT_RIBBON + Local h4 = Browser_DrawCardChrome("zone", Handle(Ar), cx4, cy4, mx, my, clicked) + Browser_DrawZoneCardBody(Ar, cx4, cy4) + If h4 Then clickedACard = True + EndIf + count = count + 1 : col = col + 1 + If col >= cols Then col = 0 : row = row + 1 + Next + Else If Browser_Category$ = "faction" + Local i = 0 + For i = 0 To 99 + If FactionNames$(i) <> "" + Local cx5 = gridX + col * (BR_CARD_W + BR_CARD_GAP) + Local cy5 = gridY + row * (BR_CARD_H + BR_CARD_GAP) + If cy5 + BR_CARD_H < sh - BR_BOT_RIBBON + Local h5 = Browser_DrawCardChrome("faction", i, cx5, cy5, mx, my, clicked) + Browser_DrawFactionCardBody(i, cx5, cy5) + If h5 Then clickedACard = True + EndIf + count = count + 1 : col = col + 1 + If col >= cols Then col = 0 : row = row + 1 + EndIf + Next + Else If Browser_Category$ = "animset" + For As.AnimSet = Each AnimSet + Local cx6 = gridX + col * (BR_CARD_W + BR_CARD_GAP) + Local cy6 = gridY + row * (BR_CARD_H + BR_CARD_GAP) + If cy6 + BR_CARD_H < sh - BR_BOT_RIBBON + Local h6 = Browser_DrawCardChrome("animset", As\ID, cx6, cy6, mx, my, clicked) + Browser_DrawAnimSetCardBody(As, cx6, cy6) + If h6 Then clickedACard = True + EndIf + count = count + 1 : col = col + 1 + If col >= cols Then col = 0 : row = row + 1 + Next + EndIf + + // Empty-state copy + If count = 0 + LoomTextCentered(sw / 2, sh / 2, "No " + Browser_Category$ + "s in this project yet.", LOOM_STONE_200_R, LOOM_STONE_200_G, LOOM_STONE_200_B) + EndIf + + Return clickedACard +End Function + + +// Draws the shared card chrome (background, hover border, kind eyebrow) +// and registers the rect for hit-testing. Returns True if this card was +// clicked this frame (and calls Threads_Focus as a side effect). +Function Browser_DrawCardChrome(kind$, refID, x, y, mx, my, clicked) + Local hovered = (mx >= x And mx < x + BR_CARD_W And my >= y And my < y + BR_CARD_H) + + LoomFill(x, y, BR_CARD_W, BR_CARD_H, LOOM_STONE_800_R, LOOM_STONE_800_G, LOOM_STONE_800_B) + + If hovered = True + LoomBorder(x, y, BR_CARD_W, BR_CARD_H, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + LoomBorder(x + 1, y + 1, BR_CARD_W - 2, BR_CARD_H - 2, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + Else + LoomBorder(x, y, BR_CARD_W, BR_CARD_H, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + EndIf + + // Top brass accent + LoomHRule(x + 12, y + 8, BR_CARD_W - 24, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + + // Stash for hit-test history + Local cr.BrowserCardRect = New BrowserCardRect + cr\Kind$ = kind$ : cr\RefID = refID + cr\X = x : cr\Y = y : cr\W = BR_CARD_W : cr\H = BR_CARD_H + If Browser_FirstCardRect = Null Then Browser_FirstCardRect = cr + + If hovered And clicked + Threads_Focus(kind$, refID) + WriteLog(LoomLog, "Browser: focused " + kind$ + "#" + Str(refID)) + Return True + EndIf + Return False +End Function + + +// ----------------------------------------------------------------------------- +// Per-kind card body content +// ----------------------------------------------------------------------------- + +Function Browser_DrawActorCardBody(Ac.Actor, x, y) + LoomText(x + 12, y + 18, Ac\Race$ + " [" + Ac\Class$ + "]", LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + Local facName$ = FactionNames$(Ac\DefaultFaction) + If facName$ = "" Then facName$ = "(no faction)" + LoomText(x + 12, y + 44, "Faction", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 12, y + 60, facName$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + LoomText(x + 180, y + 44, "XP mult", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 180, y + 60, Str(Ac\XPMultiplier), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +Function Browser_DrawItemCardBody(It.Item, x, y) + LoomText(x + 12, y + 18, It\Name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + Local typeLabel$ = Browser_ItemTypeLabel$(It\ItemType) + LoomText(x + 12, y + 44, "Type", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 12, y + 60, typeLabel$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + LoomText(x + 180, y + 44, "Value", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 180, y + 60, Str(It\Value), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +Function Browser_DrawSpellCardBody(Sp.Spell, x, y) + LoomText(x + 12, y + 18, Sp\Name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + LoomText(x + 12, y + 44, "Recharge", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 12, y + 60, Str(Sp\RechargeTime / 1000) + " s", LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + If Sp\Script$ <> "" + LoomText(x + 180, y + 44, "Script", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 180, y + 60, Sp\Script$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + EndIf +End Function + + +Function Browser_DrawZoneCardBody(Ar.Area, x, y) + LoomText(x + 12, y + 18, Ar\Name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + Local portals = 0, spawns = 0, triggers = 0 + Local i = 0 + For i = 0 To 99 + If Ar\PortalName$[i] <> "" Then portals = portals + 1 + Next + For i = 0 To 999 + If Ar\SpawnActor[i] > 0 Then spawns = spawns + 1 + Next + For i = 0 To 149 + If Ar\TriggerScript$[i] <> "" Then triggers = triggers + 1 + Next + + LoomText(x + 12, y + 44, "Portals", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 12, y + 60, Str(portals), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + LoomText(x + 110, y + 44, "Spawns", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 110, y + 60, Str(spawns), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + LoomText(x + 208, y + 44, "Triggers", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 208, y + 60, Str(triggers), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +Function Browser_DrawFactionCardBody(idx, x, y) + LoomText(x + 12, y + 18, FactionNames$(idx), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + // Member count: walk actors with DefaultFaction == idx + Local members = 0 + For Ac.Actor = Each Actor + If Ac\DefaultFaction = idx Then members = members + 1 + Next + + LoomText(x + 12, y + 44, "Members", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 12, y + 60, Str(members), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +Function Browser_DrawAnimSetCardBody(As.AnimSet, x, y) + LoomText(x + 12, y + 18, As\Name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + + // Count populated animation slots + Local clips = 0 + Local i = 0 + For i = 0 To 149 + If As\AnimName$[i] <> "" Then clips = clips + 1 + Next + + // Count actors using this anim set (M or F) + Local users = 0 + For Ac.Actor = Each Actor + If Ac\MAnimationSet = As\ID Or Ac\FAnimationSet = As\ID Then users = users + 1 + Next + + LoomText(x + 12, y + 44, "Clips", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 12, y + 60, Str(clips), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + LoomText(x + 110, y + 44, "Used by", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + 110, y + 60, Str(users), LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) +End Function + + +// ----------------------------------------------------------------------------- +// Footer +// ----------------------------------------------------------------------------- +Function Browser_DrawFooter(sw, sh) + Local y = sh - BR_BOT_RIBBON + LoomFill(0, y, sw, BR_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, "click a card to focus · follow threads in the composer · Esc to exit", LOOM_STONE_200_R, LOOM_STONE_200_G, LOOM_STONE_200_B) +End Function + + +// ----------------------------------------------------------------------------- +// Helper: human-friendly item type label. +// rcce2 stores item types as ints (defined in Inventories.bb constants). +// ----------------------------------------------------------------------------- +Function Browser_ItemTypeLabel$(t) + If t = 0 Then Return "Other" + If t = 1 Then Return "Weapon" + If t = 2 Then Return "Armour" + If t = 3 Then Return "Ring" + If t = 4 Then Return "Potion" + If t = 5 Then Return "Food" + If t = 6 Then Return "Image" + Return "Type " + Str(t) +End Function diff --git a/src/Modules/Loom/Composer.bb b/src/Modules/Loom/Composer.bb new file mode 100644 index 00000000..fb0023f0 --- /dev/null +++ b/src/Modules/Loom/Composer.bb @@ -0,0 +1,463 @@ +// ============================================================================= +// Loom/Composer.bb -- per-kind detail page for the focused entity +// ============================================================================= +// +// When the user picks an entity in the browser (or follows a thread chip), +// the composer slides in from the right and paints that entity's +// properties. Each kind has its own field layout. Reference fields render +// as thread chips (via Threads.bb) -- clicking a chip jumps and pushes the +// current focus onto the back stack. +// +// Reads: +// Loom_FocusKind$ / Loom_FocusID (from Threads.bb) +// the underlying data modules' globals (ActorList, ItemList, SpellsList, +// Each Area, FactionNames$, Each AnimSet) +// +// Writes: +// Nothing -- composer is read-only in the alpha. +// +// Per-kind field surface (only what's load-bearing for the alpha): +// +// actor : Race/Class, Description, Faction chip, M/F AnimSet chips, +// Aggressiveness, Genders, Playable, Rideable, XP multiplier +// item : Name, Type, Slot, Value, Mass, Weapon damage / Armour level +// (kind-specific), exclusive race/class, script binding +// spell : Name, Description, Recharge, exclusive race/class, script +// zone : Name, Outdoors, PvP, Gravity, entry/exit scripts, summary +// counts, portal list with target-zone chips +// faction : Name, member roster (chips to every actor with this faction) +// animset : Name, clip count, used-by roster (chips to every actor using +// this anim set as M or F animation) +// +// Public API: +// Composer_Width() +// Returns 0 when no focus, else the composer's pixel width. Browser +// uses this to dim its background (and future PRs could shrink the +// grid to make room). +// +// Composer_RenderAndUpdate(sw, sh) -> True if a chip was clicked +// Per-frame paint + chip hit-test. Returns True if any thread chip +// was clicked (the jump has already happened by then, via the chip's +// own click handler). +// ============================================================================= + + +Const CMP_W = 380 +Const CMP_TOP = 56 // matches BR_TOP_RIBBON / ZM_TOP_RIBBON +Const CMP_BOT_PAD = 36 // matches BR_BOT_RIBBON +Const CMP_PAD = 16 +Const CMP_ROW_H = 22 +Const CMP_CHIP_H = 26 + + +// ============================================================================= +// Composer_Width -- 0 when nothing's focused. +// ============================================================================= +Function Composer_Width() + If Loom_FocusKind$ = "" Then Return 0 + Return CMP_W +End Function + + +// ============================================================================= +// Composer_RenderAndUpdate -- per-frame, when something's focused. +// ============================================================================= +Function Composer_RenderAndUpdate(sw, sh) + If Loom_FocusKind$ = "" Then Return False + + Local mx = MouseX() + Local my = MouseY() + Local clicked = MouseHit(1) + + Local x = sw - CMP_W + Local y = CMP_TOP + Local w = CMP_W + Local h = sh - CMP_TOP - CMP_BOT_PAD + + // Panel chrome -- brass left rule signals the primary surface + LoomFill(x, y, w, h, LOOM_STONE_850_R, LOOM_STONE_850_G, LOOM_STONE_850_B) + LoomBorder(x, y, w, h, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + LoomFill(x, y, 3, h, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + + // Title block + Local kindLabel$ = Composer_KindLabel$(Loom_FocusKind$) + Local name$ = Threads_LookupName$(Loom_FocusKind$, Loom_FocusID) + If name$ = "" Then name$ = "(unknown)" + + LoomText(x + CMP_PAD, y + CMP_PAD, kindLabel$, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(x + CMP_PAD, y + CMP_PAD + 16, name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + LoomHRule(x + CMP_PAD, y + CMP_PAD + 38, w - CMP_PAD * 2, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + + // Body + Local bodyY = y + CMP_PAD + 50 + Local bodyH = h - (bodyY - y) - 24 + Local clickedAChip = False + + If Loom_FocusKind$ = "actor" + clickedAChip = Composer_RenderActor(x, bodyY, w, bodyH, mx, my, clicked) + Else If Loom_FocusKind$ = "item" + clickedAChip = Composer_RenderItem(x, bodyY, w, bodyH, mx, my, clicked) + Else If Loom_FocusKind$ = "spell" + clickedAChip = Composer_RenderSpell(x, bodyY, w, bodyH, mx, my, clicked) + Else If Loom_FocusKind$ = "zone" + clickedAChip = Composer_RenderZone(x, bodyY, w, bodyH, mx, my, clicked) + Else If Loom_FocusKind$ = "faction" + clickedAChip = Composer_RenderFaction(x, bodyY, w, bodyH, mx, my, clicked) + Else If Loom_FocusKind$ = "animset" + clickedAChip = Composer_RenderAnimSet(x, bodyY, w, bodyH, mx, my, clicked) + EndIf + + // Footer: back-stack hint + Local stackSize = ListSize(Loom_BackStack) + Local footMsg$ = "Esc returns to browser" + If stackSize > 0 + footMsg$ = "Esc walks back · " + Str(stackSize) + " in trail" + EndIf + LoomText(x + CMP_PAD, y + h - 22, footMsg$, LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) + + Return clickedAChip +End Function + + +// ----------------------------------------------------------------------------- +// Layout helpers +// ----------------------------------------------------------------------------- + +// label : value row. Label brass, value parchment. Returns the next Y. +Function Composer_Row(panelX, panelW, rowY, label$, value$) + LoomText(panelX + CMP_PAD, rowY, label$, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + LoomText(panelX + CMP_PAD + 120, rowY, value$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + Return rowY + CMP_ROW_H +End Function + + +// label : thread chip row. Returns the next Y + whether the chip was clicked. +// Blitz has no out-params; we OR-fold into the global Composer_ChipHit flag. +Global Composer_ChipHit = False +Function Composer_ChipRow(panelX, panelW, rowY, label$, kind$, refID, mx, my, clicked) + LoomText(panelX + CMP_PAD, rowY + 4, label$, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + + Local chipX = panelX + CMP_PAD + 120 + Local chipW = panelW - CMP_PAD * 2 - 120 + Local hit = Threads_RenderChip(chipX, rowY, chipW, CMP_CHIP_H, kind$, refID, mx, my, clicked) + If hit Then Composer_ChipHit = True + + Return rowY + CMP_CHIP_H + 4 +End Function + + +// Section header inside a panel body. Used to break long composer pages into +// labeled groups (e.g. "Members" header in faction composer). +Function Composer_SectionHeader(panelX, panelW, rowY, title$) + LoomHRule(panelX + CMP_PAD, rowY + 6, panelW - CMP_PAD * 2, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + LoomText(panelX + CMP_PAD, rowY + 10, title$, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + Return rowY + 28 +End Function + + +Function Composer_KindLabel$(kind$) + If kind$ = "actor" Then Return "ACTOR" + If kind$ = "item" Then Return "ITEM" + If kind$ = "spell" Then Return "SPELL" + If kind$ = "zone" Then Return "ZONE" + If kind$ = "faction" Then Return "FACTION" + If kind$ = "animset" Then Return "ANIMATION SET" + Return Upper$(kind$) +End Function + + +// ============================================================================= +// Per-kind body renderers +// ============================================================================= + +Function Composer_RenderActor(panelX, bodyY, panelW, bodyH, mx, my, clicked) + Composer_ChipHit = False + If Loom_FocusID < 0 Or Loom_FocusID > 65535 Then Return False + Local A.Actor = ActorList(Loom_FocusID) + If A = Null Then Return False + + Local y = bodyY + y = Composer_Row(panelX, panelW, y, "ID", Str(A\ID)) + y = Composer_Row(panelX, panelW, y, "Race", A\Race$) + y = Composer_Row(panelX, panelW, y, "Class", A\Class$) + y = Composer_Row(panelX, panelW, y, "Aggressiveness",Composer_ActorAggLabel$(A\Aggressiveness)) + y = Composer_Row(panelX, panelW, y, "Genders", Composer_ActorGenderLabel$(A\Genders)) + y = Composer_Row(panelX, panelW, y, "Playable", Composer_BoolLabel$(A\Playable)) + y = Composer_Row(panelX, panelW, y, "Rideable", Composer_BoolLabel$(A\Rideable)) + y = Composer_Row(panelX, panelW, y, "XP multiplier", Str(A\XPMultiplier)) + + y = Composer_SectionHeader(panelX, panelW, y, "Threads") + + y = Composer_ChipRow(panelX, panelW, y, "Faction", "faction", A\DefaultFaction, mx, my, clicked) + y = Composer_ChipRow(panelX, panelW, y, "M anim set", "animset", A\MAnimationSet, mx, my, clicked) + y = Composer_ChipRow(panelX, panelW, y, "F anim set", "animset", A\FAnimationSet, mx, my, clicked) + + Return Composer_ChipHit +End Function + + +Function Composer_RenderItem(panelX, bodyY, panelW, bodyH, mx, my, clicked) + Composer_ChipHit = False + If Loom_FocusID < 0 Or Loom_FocusID > 65534 Then Return False + Local It.Item = ItemList(Loom_FocusID) + If It = Null Then Return False + + Local y = bodyY + y = Composer_Row(panelX, panelW, y, "ID", Str(It\ID)) + y = Composer_Row(panelX, panelW, y, "Type", Browser_ItemTypeLabel$(It\ItemType)) + y = Composer_Row(panelX, panelW, y, "Slot", Str(It\SlotType)) + y = Composer_Row(panelX, panelW, y, "Value", Str(It\Value)) + y = Composer_Row(panelX, panelW, y, "Mass", Str(It\Mass)) + y = Composer_Row(panelX, panelW, y, "Stackable", Composer_BoolLabel$(It\Stackable)) + y = Composer_Row(panelX, panelW, y, "Breakable", Composer_BoolLabel$(It\TakesDamage)) + + // Weapon-specific + If It\ItemType = 1 + y = Composer_SectionHeader(panelX, panelW, y, "Weapon") + y = Composer_Row(panelX, panelW, y, "Damage", Str(It\WeaponDamage)) + y = Composer_Row(panelX, panelW, y, "Weapon type", Str(It\WeaponType)) + If It\Range# > 0.0 + y = Composer_Row(panelX, panelW, y, "Range", Composer_FormatFloat$(It\Range#)) + EndIf + EndIf + + // Armour-specific + If It\ItemType = 2 + y = Composer_SectionHeader(panelX, panelW, y, "Armour") + y = Composer_Row(panelX, panelW, y, "Armour level", Str(It\ArmourLevel)) + EndIf + + // Restrictions + If It\ExclusiveRace$ <> "" Or It\ExclusiveClass$ <> "" + y = Composer_SectionHeader(panelX, panelW, y, "Restricted to") + If It\ExclusiveRace$ <> "" + y = Composer_Row(panelX, panelW, y, "Race", It\ExclusiveRace$) + EndIf + If It\ExclusiveClass$ <> "" + y = Composer_Row(panelX, panelW, y, "Class", It\ExclusiveClass$) + EndIf + EndIf + + // Script + If It\Script$ <> "" + y = Composer_SectionHeader(panelX, panelW, y, "Script") + y = Composer_Row(panelX, panelW, y, "Bound", It\Script$) + If It\SMethod$ <> "" + y = Composer_Row(panelX, panelW, y, "Method", It\SMethod$) + EndIf + EndIf + + Return Composer_ChipHit +End Function + + +Function Composer_RenderSpell(panelX, bodyY, panelW, bodyH, mx, my, clicked) + Composer_ChipHit = False + If Loom_FocusID < 0 Or Loom_FocusID > 65534 Then Return False + Local S.Spell = SpellsList(Loom_FocusID) + If S = Null Then Return False + + Local y = bodyY + y = Composer_Row(panelX, panelW, y, "ID", Str(S\ID)) + y = Composer_Row(panelX, panelW, y, "Recharge", Str(S\RechargeTime) + " ms") + + If S\Description$ <> "" + y = Composer_SectionHeader(panelX, panelW, y, "Description") + // Description can be long; clip to one line. Future PR: word-wrap. + Local desc$ = S\Description$ + If Len(desc$) > 60 Then desc$ = Left$(desc$, 57) + "..." + LoomText(panelX + CMP_PAD, y, desc$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + y = y + CMP_ROW_H + 4 + EndIf + + If S\ExclusiveRace$ <> "" Or S\ExclusiveClass$ <> "" + y = Composer_SectionHeader(panelX, panelW, y, "Restricted to") + If S\ExclusiveRace$ <> "" Then y = Composer_Row(panelX, panelW, y, "Race", S\ExclusiveRace$) + If S\ExclusiveClass$ <> "" Then y = Composer_Row(panelX, panelW, y, "Class", S\ExclusiveClass$) + EndIf + + If S\Script$ <> "" + y = Composer_SectionHeader(panelX, panelW, y, "Script") + y = Composer_Row(panelX, panelW, y, "Bound", S\Script$) + If S\SMethod$ <> "" Then y = Composer_Row(panelX, panelW, y, "Method", S\SMethod$) + EndIf + + Return Composer_ChipHit +End Function + + +Function Composer_RenderZone(panelX, bodyY, panelW, bodyH, mx, my, clicked) + Composer_ChipHit = False + Local Ar.Area = Object.Area(Loom_FocusID) + If Ar = Null Then Return False + + Local y = bodyY + y = Composer_Row(panelX, panelW, y, "Name", Ar\Name$) + y = Composer_Row(panelX, panelW, y, "Outdoors", Composer_BoolLabel$(Ar\Outdoors)) + y = Composer_Row(panelX, panelW, y, "PvP", Composer_BoolLabel$(Ar\PvP)) + y = Composer_Row(panelX, panelW, y, "Gravity", Str(Ar\Gravity)) + + // Counts + Local portals = 0, spawns = 0, triggers = 0, waypoints = 0 + Local i = 0 + For i = 0 To 99 + If Ar\PortalName$[i] <> "" Then portals = portals + 1 + Next + For i = 0 To 999 + If Ar\SpawnActor[i] > 0 Then spawns = spawns + 1 + Next + For i = 0 To 149 + If Ar\TriggerScript$[i] <> "" Then triggers = triggers + 1 + Next + For i = 0 To 1999 + If Ar\WaypointX#[i] <> 0.0 Or Ar\WaypointZ#[i] <> 0.0 Then waypoints = waypoints + 1 + Next + + y = Composer_SectionHeader(panelX, panelW, y, "Contents") + y = Composer_Row(panelX, panelW, y, "Portals", Str(portals)) + y = Composer_Row(panelX, panelW, y, "Spawns", Str(spawns)) + y = Composer_Row(panelX, panelW, y, "Triggers", Str(triggers)) + y = Composer_Row(panelX, panelW, y, "Waypoints", Str(waypoints)) + + // Scripts + If Ar\EntryScript$ <> "" Or Ar\ExitScript$ <> "" + y = Composer_SectionHeader(panelX, panelW, y, "Scripts") + If Ar\EntryScript$ <> "" Then y = Composer_Row(panelX, panelW, y, "Entry", Ar\EntryScript$) + If Ar\ExitScript$ <> "" Then y = Composer_Row(panelX, panelW, y, "Exit", Ar\ExitScript$) + EndIf + + // Portal links -- one chip per portal whose target resolves to a zone + // we know about. Builds the most-useful thread set zones can offer. + If portals > 0 + y = Composer_SectionHeader(panelX, panelW, y, "Portal links") + Local p = 0 + For p = 0 To 99 + If Ar\PortalName$[p] <> "" And y < bodyY + bodyH - CMP_CHIP_H - 24 + Local targetHandle = Composer_FindZoneByName(Ar\PortalLinkArea$[p]) + If targetHandle <> 0 + y = Composer_ChipRow(panelX, panelW, y, Ar\PortalName$[p], "zone", targetHandle, mx, my, clicked) + Else + // Unknown target -- render a brass label that says where it points + LoomText(panelX + CMP_PAD, y + 4, Ar\PortalName$[p], LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + Local tgt$ = Ar\PortalLinkArea$[p] + If tgt$ = "" Then tgt$ = "(no target)" + LoomText(panelX + CMP_PAD + 120, y + 4, tgt$, LOOM_DANGER_R, LOOM_DANGER_G, LOOM_DANGER_B) + y = y + CMP_ROW_H + EndIf + EndIf + Next + EndIf + + Return Composer_ChipHit +End Function + + +Function Composer_RenderFaction(panelX, bodyY, panelW, bodyH, mx, my, clicked) + Composer_ChipHit = False + Local idx = Loom_FocusID + If idx < 0 Or idx > 99 Then Return False + + Local y = bodyY + y = Composer_Row(panelX, panelW, y, "Name", FactionNames$(idx)) + y = Composer_Row(panelX, panelW, y, "Index", Str(idx)) + + // Members -- every actor whose DefaultFaction matches this index. + // Each member renders as an actor chip. Capped to whatever fits in + // the panel body (no scrolling yet). + y = Composer_SectionHeader(panelX, panelW, y, "Members") + + Local memberCount = 0 + For Ac.Actor = Each Actor + If Ac\DefaultFaction = idx And y < bodyY + bodyH - CMP_CHIP_H - 24 + y = Composer_ChipRow(panelX, panelW, y, "", "actor", Ac\ID, mx, my, clicked) + memberCount = memberCount + 1 + EndIf + Next + + If memberCount = 0 + LoomText(panelX + CMP_PAD, y + 4, "(no members)", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) + EndIf + + Return Composer_ChipHit +End Function + + +Function Composer_RenderAnimSet(panelX, bodyY, panelW, bodyH, mx, my, clicked) + Composer_ChipHit = False + Local targetID = Loom_FocusID + + // AnimSet is iterated, not indexed -- walk to find. + Local A.AnimSet = Null + For As.AnimSet = Each AnimSet + If As\ID = targetID Then A = As : Exit + Next + If A = Null Then Return False + + Local y = bodyY + y = Composer_Row(panelX, panelW, y, "Name", A\Name$) + y = Composer_Row(panelX, panelW, y, "ID", Str(A\ID)) + + Local clips = 0 + Local i = 0 + For i = 0 To 149 + If A\AnimName$[i] <> "" Then clips = clips + 1 + Next + y = Composer_Row(panelX, panelW, y, "Clips", Str(clips)) + + y = Composer_SectionHeader(panelX, panelW, y, "Used by") + Local userCount = 0 + For Ac.Actor = Each Actor + If (Ac\MAnimationSet = targetID Or Ac\FAnimationSet = targetID) And y < bodyY + bodyH - CMP_CHIP_H - 24 + y = Composer_ChipRow(panelX, panelW, y, "", "actor", Ac\ID, mx, my, clicked) + userCount = userCount + 1 + EndIf + Next + + If userCount = 0 + LoomText(panelX + CMP_PAD, y + 4, "(no users)", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) + EndIf + + Return Composer_ChipHit +End Function + + +// ============================================================================= +// Value formatters +// ============================================================================= + +Function Composer_BoolLabel$(b) + If b Then Return "Yes" + Return "No" +End Function + +Function Composer_FormatFloat$(v#) + Local rounded# = Float(Int(v# * 10.0)) / 10.0 + Return Str$(rounded#) +End Function + +Function Composer_ActorAggLabel$(a) + If a = 0 Then Return "Passive" + If a = 1 Then Return "Defensive" + If a = 2 Then Return "Always attacks" + If a = 3 Then Return "Non-combatant" + Return Str(a) +End Function + +Function Composer_ActorGenderLabel$(g) + If g = 0 Then Return "Both" + If g = 1 Then Return "Male only" + If g = 2 Then Return "Female only" + If g = 3 Then Return "No gender" + Return Str(g) +End Function + + +// Resolve a zone name to its Handle. Returns 0 if not found. +// Used by zone composer to wire portal targets to thread chips. +Function Composer_FindZoneByName(name$) + If name$ = "" Then Return 0 + For Ar.Area = Each Area + If Upper$(Ar\Name$) = Upper$(name$) Then Return Handle(Ar) + Next + Return 0 +End Function diff --git a/src/Modules/Loom/Threads.bb b/src/Modules/Loom/Threads.bb new file mode 100644 index 00000000..e1d4e204 --- /dev/null +++ b/src/Modules/Loom/Threads.bb @@ -0,0 +1,273 @@ +// ============================================================================= +// Loom/Threads.bb -- focus + back-stack navigation + clickable thread chips +// ============================================================================= +// +// The centerpiece of the Loom design: every reference between entities is +// rendered as a clickable chip. Clicking a chip jumps the focused entity +// from the source to the target and pushes the source onto a back stack; +// Esc pops the stack to walk back through the navigation chain. +// +// Hero flow this enables: +// browse to "Goblin Shaman" -> composer paints the actor -> +// click [◈ Forest Tribe] chip on its faction field -> +// composer paints Forest Tribe with its member roster -> +// click [◈ Goblin Scout] in that roster -> +// composer paints that actor -> +// Esc -> back to Forest Tribe -> +// Esc -> back to Goblin Shaman. +// +// Entity-kind identifier convention (kept consistent across Browser / +// Composer / Threads / Loom.bb): +// +// "" no entity focused +// "actor" refID = Actor\ID (array index in ActorList) +// "item" refID = Item\ID (array index in ItemList) +// "spell" refID = Spell\ID (array index in SpellsList) +// "zone" refID = Handle(Area) +// "faction" refID = FactionNames$ array index 0..99 +// "animset" refID = AnimSet\ID +// +// Public API: +// Threads_Init() +// Initialize the back stack. Call once at startup. +// +// Threads_Focus(kind$, refID) +// Set the focus directly. Does NOT push the previous focus onto the +// back stack. Use when entering composer from the browser, or when +// restoring after a Threads_Back. +// +// Threads_Jump(kind$, refID) +// Push the current focus, then set the new focus. This is what every +// thread-chip click does. No-op if jumping to the same entity that's +// already focused. +// +// Threads_Back() -> True if popped +// Restore the most recently pushed focus. Returns False (and leaves +// focus unchanged) when the back stack is empty. +// +// Threads_ClearStack() +// Drop the entire back stack. Call when leaving composer back to the +// browser so a new browse session doesn't inherit stale history. +// +// Threads_LookupName$(kind$, refID) +// Resolve the entity to its display name. Returns "" if the reference +// doesn't resolve (the entity was deleted) -- callers treat empty as +// a broken-ref signal and render the chip with broken styling. +// +// Threads_RenderChip(x, y, w, h, kind$, refID, mx, my, clicked) -> True if click consumed +// Draws a chip rect with the kind icon + target name. Hit-tests the +// mouse against the chip; if clicked, calls Threads_Jump internally +// and returns True. The composer just lays out chips and watches the +// return value to react when the user follows a thread. +// ============================================================================= + + +// ----------------------------------------------------------------------------- +// State -- focus + back stack +// ----------------------------------------------------------------------------- +Global Loom_FocusKind$ = "" +Global Loom_FocusID = 0 + +Type LoomFocusEntry + Field Kind$ + Field RefID +End Type +Global Loom_BackStack.BBList = Null + + +// ----------------------------------------------------------------------------- +// Chip layout constants +// ----------------------------------------------------------------------------- +Const CHIP_PAD_X = 10 +Const CHIP_ICON_W = 18 + + +// ============================================================================= +// Threads_Init +// ============================================================================= +Function Threads_Init() + Loom_BackStack = CreateList() + Loom_FocusKind$ = "" + Loom_FocusID = 0 +End Function + + +// ============================================================================= +// Threads_Focus -- direct set, no stack push +// ============================================================================= +Function Threads_Focus(kind$, refID) + Loom_FocusKind$ = kind$ + Loom_FocusID = refID +End Function + + +// ============================================================================= +// Threads_Jump -- push current focus, set new +// ============================================================================= +Function Threads_Jump(kind$, refID) + // No-op on self-link + If kind$ = Loom_FocusKind$ And refID = Loom_FocusID Then Return + + // Push current focus (if any) onto the back stack + If Loom_FocusKind$ <> "" + Local prev.LoomFocusEntry = New LoomFocusEntry + prev\Kind$ = Loom_FocusKind$ + prev\RefID = Loom_FocusID + ListAdd(Loom_BackStack, prev) + EndIf + + Loom_FocusKind$ = kind$ + Loom_FocusID = refID + + WriteLog(LoomLog, "Threads: jumped to " + kind$ + "#" + Str(refID) + " (back stack: " + Str(ListSize(Loom_BackStack)) + ")") +End Function + + +// ============================================================================= +// Threads_Back -- pop and focus; returns True if popped +// ============================================================================= +Function Threads_Back() + Local n = ListSize(Loom_BackStack) + If n = 0 Then Return False + + // Pop the last entry + Local prev.LoomFocusEntry = ListAt(Loom_BackStack, n - 1) + If prev = Null Then Return False + Local kind$ = prev\Kind$ + Local refID = prev\RefID + ListRemove(Loom_BackStack, n - 1) + + Loom_FocusKind$ = kind$ + Loom_FocusID = refID + + WriteLog(LoomLog, "Threads: back to " + kind$ + "#" + Str(refID) + " (back stack: " + Str(ListSize(Loom_BackStack)) + ")") + Return True +End Function + + +// ============================================================================= +// Threads_ClearStack +// ============================================================================= +Function Threads_ClearStack() + If Loom_BackStack <> Null Then ListClear(Loom_BackStack) +End Function + + +// ============================================================================= +// Threads_LookupName$ -- resolve to display name. Returns "" if not found. +// ============================================================================= +Function Threads_LookupName$(kind$, refID) + If kind$ = "actor" + If refID < 0 Or refID > 65535 Then Return "" + Local Ac.Actor = ActorList(refID) + If Ac = Null Then Return "" + Return Ac\Race$ + " [" + Ac\Class$ + "]" + EndIf + + If kind$ = "item" + If refID < 0 Or refID > 65534 Then Return "" + Local It.Item = ItemList(refID) + If It = Null Then Return "" + Return It\Name$ + EndIf + + If kind$ = "spell" + If refID < 0 Or refID > 65534 Then Return "" + Local Sp.Spell = SpellsList(refID) + If Sp = Null Then Return "" + Return Sp\Name$ + EndIf + + If kind$ = "zone" + Local Ar.Area = Object.Area(refID) + If Ar = Null Then Return "" + Return Ar\Name$ + EndIf + + If kind$ = "faction" + If refID < 0 Or refID > 99 Then Return "" + Local n$ = FactionNames$(refID) + If n$ = "" Then Return "" + Return n$ + EndIf + + If kind$ = "animset" + // AnimSet is iterated, not array-indexed -- walk to find by ID. + For As.AnimSet = Each AnimSet + If As\ID = refID Then Return As\Name$ + Next + Return "" + EndIf + + Return "" +End Function + + +// ============================================================================= +// Threads_RenderChip -- draw a clickable chip, hit-test, jump on click. +// +// Returns True if the chip consumed a click this frame. The composer can +// ignore the return value if it doesn't need it -- the jump has already +// happened by then. +// ============================================================================= +Function Threads_RenderChip(x, y, w, h, kind$, refID, mx, my, clicked) + Local hovered = (mx >= x And mx < x + w And my >= y And my < y + h) + + Local name$ = Threads_LookupName$(kind$, refID) + Local broken = (name$ = "") + If broken = True Then name$ = "(broken " + kind$ + " #" + Str(refID) + ")" + + // Background + If hovered = True And broken = False + LoomFill(x, y, w, h, LOOM_ARCANE_900_R, LOOM_ARCANE_900_G, LOOM_ARCANE_900_B) + Else + LoomFill(x, y, w, h, LOOM_STONE_800_R, LOOM_STONE_800_G, LOOM_STONE_800_B) + EndIf + + // Border -- broken refs get a danger-red border so they read as wrong + If broken = True + LoomBorder(x, y, w, h, LOOM_DANGER_R, LOOM_DANGER_G, LOOM_DANGER_B) + Else If hovered = True + LoomBorder(x, y, w, h, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + LoomBorder(x + 1, y + 1, w - 2, h - 2, LOOM_ARCANE_500_R, LOOM_ARCANE_500_G, LOOM_ARCANE_500_B) + Else + LoomBorder(x, y, w, h, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) + EndIf + + // Kind icon (text glyph in brass, left-aligned) + Local icon$ = Threads_KindGlyph$(kind$) + LoomText(x + CHIP_PAD_X, y + 5, icon$, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + + // Name (parchment, dimmer if broken) + If broken = True + LoomText(x + CHIP_PAD_X + CHIP_ICON_W, y + 5, name$, LOOM_DANGER_R, LOOM_DANGER_G, LOOM_DANGER_B) + Else + LoomText(x + CHIP_PAD_X + CHIP_ICON_W, y + 5, name$, LOOM_PARCHMENT_100_R, LOOM_PARCHMENT_100_G, LOOM_PARCHMENT_100_B) + EndIf + + // Right-side arrow indicating "jump" + If broken = False + LoomText(x + w - 16, y + 5, ">", LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + EndIf + + // Click consumed? + If hovered And clicked And broken = False + Threads_Jump(kind$, refID) + Return True + EndIf + Return False +End Function + + +// Short glyph used as the kind icon in chips and cards. Picked from common +// Unicode that the default Blitz font renders (ASCII fallbacks where it +// doesn't). +Function Threads_KindGlyph$(kind$) + If kind$ = "actor" Then Return "A" + If kind$ = "item" Then Return "I" + If kind$ = "spell" Then Return "S" + If kind$ = "zone" Then Return "Z" + If kind$ = "faction" Then Return "F" + If kind$ = "animset" Then Return "M" + Return "?" +End Function