mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
Merge pull request #85 from TemujinCalidius/feat/admin-studio
feat: SurvivorCore Studio — Engine Config editor, content overrides, window/sidebar rework
This commit is contained in:
@@ -5,6 +5,34 @@ All notable changes to SurvivorCore are recorded here. The format follows
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased`
|
||||
is promoted to the new version and `main` is tagged `vX.Y.Z`.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **Engine Config, no-code** (#21) — a new persisted instance, **`SurvivorCoreEngineConfig`**,
|
||||
layers owner overrides over EVERY engine Config section (Movement incl. feedback asset ids,
|
||||
Combat + Bow, Mobs, Harvesting, Crafting, Inventory scalars, Consequences, LootBags, Quests,
|
||||
Achievements, and UI incl. the full **Theme** — colors and fonts). Same locked, **deltas-only**
|
||||
model as `SurvivalStatsConfig`: an attribute exists only where the owner diverged from the
|
||||
engine default, so untouched fields keep following engine updates. Applied as the FIRST step of
|
||||
`start()`/`startClient()` (edits take effect on the next Play); invalid saved values warn and
|
||||
are skipped, never fatal. The schema (`src/shared/EngineConfig.luau`, ~94 typed fields across 11
|
||||
sections) is the single source of truth the plugin renders from.
|
||||
- **Content overrides** (#40) — `SurvivorCoreContent/Overrides/<Category>` entries **field-merge
|
||||
onto defs registered from code** (previously untouchable no-code: the loader skips registered
|
||||
ids). Deltas-only: blank = inherit; deleting an override restores the code def; an id matching
|
||||
no def **warns at boot** (typo backstop). Applied before components bind and before the client
|
||||
data caches publish, so tooltips and gatherables see merged values. New
|
||||
`Registry.applyOverrides(folder)`.
|
||||
- **SurvivorCore Studio** — the admin plugin rebuilt as its own **floating window** (drag-dockable,
|
||||
660×580 default) with a **sidebar**: Overview (engine status + content counts), Survival Stats,
|
||||
**Engine Config** (one page per section; color fields edited as `R, G, B` with a live swatch,
|
||||
fonts by name with a cycle button; per-section reset), and Content (one page per category with
|
||||
count badges, **+ Override** authoring, blue override pills). A rail **search box** finds any
|
||||
content entry by id/name across categories with editable results in place. Last-open page is
|
||||
remembered; undo/redo now refreshes the forms. New shared plugin visual system
|
||||
(`plugin/Theme.luau`) replaces the copy-pasted styling. *(Window identity changed — your old
|
||||
dock position resets once.)*
|
||||
|
||||
## 0.7.4 — 2026-07-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -40,8 +40,11 @@ authoring tools. If you know Roblox Studio, you can build a survival game.
|
||||
- **Hunting & loot bags** — slain animals leave butcherable carcasses (knife required, real
|
||||
yields); player death drops everything into a lootable bag with a countdown — get back to it
|
||||
before it's gone.
|
||||
- **No-code admin plugin** — create items, weapons, ammo and mobs from a Studio form (damage,
|
||||
range, arrow curve, weight, aggro, leash) and drop them into the world. No scripting.
|
||||
- **SurvivorCore Studio (no-code admin plugin)** — one floating window with sidebar + search:
|
||||
tune **every engine config section** (movement, combat, mobs, loot bags, UI theme colors &
|
||||
fonts, …) as locked deltas that survive engine updates; create items, weapons, ammo, mobs,
|
||||
quests and achievements from forms; **override code-registered content** field-by-field; drop
|
||||
any of it into the world. No scripting.
|
||||
|
||||
Everything is **content-free by design**, **server-authoritative**, and **restyleable** — the UI
|
||||
is real Instances driven by attributes, and every default is overridable.
|
||||
|
||||
+104
-51
@@ -1,20 +1,32 @@
|
||||
# SurvivorCore admin plugin
|
||||
# SurvivorCore Studio (the admin plugin)
|
||||
|
||||
A small **Studio plugin** that gives the experience owner friendly forms instead of hand-editing
|
||||
Attributes in the Explorer. It adds two toolbar buttons under **SurvivorCore**:
|
||||
A **Studio plugin** that gives the experience owner friendly forms instead of hand-editing
|
||||
Attributes in the Explorer. One **SurvivorCore › Studio** toolbar button opens one **floating
|
||||
window** (drag-dock it anywhere) with a sidebar of editors:
|
||||
|
||||
- **Overview** — is the engine synced, what content exists (click through), how the model works.
|
||||
- **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig`
|
||||
instance (the deltas-only, locked model below).
|
||||
instance (the deltas-only, locked model below), with an Edit-mode **HUD preview**.
|
||||
- **Engine Config** — every engine Config section (issue #21): Movement (speeds, energy, audio,
|
||||
feedback **asset ids**), Combat + Bow, Mobs & AI, Harvesting, Crafting, Inventory, Consequences,
|
||||
Loot bags, Quests, Achievements, and **UI & theme** (keybinds, chat placement, layout — and the
|
||||
full **Theme**: colors edited as `R, G, B` with a live swatch, fonts by name). Same deltas-only
|
||||
model, persisted on **`SurvivorCoreEngineConfig`**; edits apply on the **next Play**.
|
||||
- **Content** — create/edit/delete **items**, **weapons**, **arrows**, **gatherable resources**,
|
||||
**mobs**, **quests** and **achievements** with no code (the Builder slice). It writes
|
||||
`SurvivorCoreContent` instances the engine loads at `start()` — see
|
||||
[content-authoring.md](content-authoring.md). Gatherables/mobs get **+ Add to World**, weapons
|
||||
**+ Tool model**, quests **+ Quest giver** — each drops the tagged instance in front of the
|
||||
camera. Unlike the stats editor, content is full owner-authored defs (not deltas). Every edit is
|
||||
one Studio undo step.
|
||||
**mobs**, **quests** and **achievements** with no code, one page per category with entry counts —
|
||||
plus **Overrides** that tune defs registered *from code* (issue #40, below). Gatherables/mobs get
|
||||
**+ Add to World**, weapons **+ Tool model**, quests **+ Quest giver**. See
|
||||
[content-authoring.md](content-authoring.md).
|
||||
|
||||
The rest of this page covers the Survival Stats editor; both install the same way. It's the
|
||||
[Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
|
||||
The **search box** at the top of the sidebar finds any content entry by id or name across every
|
||||
category and shows fully **editable** results in place. The window remembers your last-open page.
|
||||
Every edit is one Studio **undo** step (and undo/redo refreshes the forms).
|
||||
|
||||
This is the [Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
|
||||
|
||||
> **Upgrading from the tabbed panel?** The window identity changed (`SurvivorCore Studio`), so
|
||||
> Studio forgets the old panel's dock position **once** — the new window opens floating; dock it
|
||||
> wherever you like and Studio remembers from then on.
|
||||
|
||||
> 📹 **Demos:** [HUD, survival stats & the admin plugin](https://makertube.net/w/xqX7wfRpTqd9L9BkozCS1P) · [no-code item & gatherable creation](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE) · [no-code weapon, ammo & mob creation](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU) · [no-code quest & achievement creation](https://makertube.net/w/uSGJ2MHEFjSSKxMiJBJ6Y5)
|
||||
|
||||
@@ -44,16 +56,17 @@ SurvivorCoreStatAdmin.rbxm` — and drop it into that folder, or open it from St
|
||||
|
||||
Then **restart Studio** — a brand-new plugin's toolbar only registers when Studio loads it. (After
|
||||
this first install, re-running the `--plugin` build hot-reloads the plugin live, no restart needed.)
|
||||
A **SurvivorCore › Survival Stats** button then appears on the **Plugins** ribbon tab; click it to
|
||||
toggle the dock widget.
|
||||
A **SurvivorCore › Studio** button then appears on the **Plugins** ribbon tab; click it to toggle
|
||||
the window.
|
||||
|
||||
> **Open a place that has the engine in it.** The plugin tunes the stats of whatever place is open,
|
||||
> reading the live roster from `ReplicatedStorage.SurvivorCore` — so sync the engine (or drop in
|
||||
> `SurvivorCore.rbxm`) first. With no engine present it opens to an instructional empty state and
|
||||
> writes nothing. Note that restarting Studio drops an *unsaved* Rojo-synced place, so reconnect
|
||||
> Rojo and re-sync (or save the place before restarting) to bring the engine back.
|
||||
> **Open a place that has the engine in it.** The plugin edits whatever place is open, reading the
|
||||
> stat roster and the Engine Config schema live from `ReplicatedStorage.SurvivorCore` — so sync the
|
||||
> engine (or drop in `SurvivorCore.rbxm`) first. With no engine present it opens to an
|
||||
> instructional empty state and writes nothing. Note that restarting Studio drops an *unsaved*
|
||||
> Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring
|
||||
> the engine back.
|
||||
|
||||
## Using it
|
||||
## Survival Stats
|
||||
|
||||
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
|
||||
you've set one, otherwise the live engine default):
|
||||
@@ -64,53 +77,93 @@ you've set one, otherwise the live engine default):
|
||||
- **Icon** — the HUD icon asset id.
|
||||
- **Value format** — `fraction` (`99/100`) · `percent` · `value` · `none`.
|
||||
|
||||
Edit a field (type a number / click to toggle or cycle) and it's applied immediately. The dot at the
|
||||
left of a row is **filled + blue when that field is overridden**, hollow when it's following the
|
||||
engine default. Click the dot to **reset** the field. Every edit is a single **undo** step.
|
||||
Edit a field (type a number / click to toggle or cycle) and it's applied immediately — stats apply
|
||||
**live**, no restart. The dot at the left of a row is **filled + blue when that field is
|
||||
overridden**, hollow when it's following the engine default. Click the dot to **reset** the field.
|
||||
|
||||
It reads the stat roster + defaults live from the engine in the place, so it always reflects the
|
||||
version you're running. With no engine synced it shows an instructional empty state and writes
|
||||
nothing.
|
||||
The plugin can tune **only** the seven owner-facing fields above. It deliberately **cannot** touch
|
||||
`Invert` or `DangerHigh` — those are engine-owned stat *semantics*; a guardrail makes them
|
||||
impossible to write.
|
||||
|
||||
### Preview the HUD in Edit mode
|
||||
|
||||
Roblox doesn't run the HUD's client binder in Studio's **Edit** view, so an authored `SurvivalHud`
|
||||
normally shows its static template there — full bars, blank readouts, and only the shipped *default*
|
||||
icons (an icon override you set above isn't visible until you press Play). The footer's **Preview
|
||||
HUD** button paints, in Edit, what the running game *would* render: each bar's resolved icon
|
||||
(including your overrides), a sample partial fill, and a sample value readout — so you can tune and
|
||||
**see the result without pressing Play**. **Clear** restores the HUD; both are a single **undo**
|
||||
step. (Preview resolves icons exactly like the engine — per-bar `Icon` attribute › the stat's
|
||||
effective `icon` — and edits the `SurvivalHud` in StarterGui, so on the Rojo-mounted demo a re-sync
|
||||
also resets it.)
|
||||
normally shows its static template there. The footer's **Preview HUD** button paints, in Edit, what
|
||||
the running game *would* render: each bar's resolved icon (including your overrides), a sample
|
||||
partial fill, and a sample value readout — so you can tune and **see the result without pressing
|
||||
Play**. **Clear** restores the HUD; both are a single **undo** step.
|
||||
|
||||
## Engine Config
|
||||
|
||||
Every page under **Engine Config** edits one engine Config section with the exact same row model as
|
||||
the stats editor (override dot, engine-default placeholder, blue = overridden). Differences worth
|
||||
knowing:
|
||||
|
||||
- **Persisted on `SurvivorCoreEngineConfig`** (a Configuration in ReplicatedStorage, one child per
|
||||
section, one grandchild per group) — created on your first real override, deltas-only, never
|
||||
seeded, never overwritten by the engine.
|
||||
- **Applies on the next Play / server start.** The engine layers the instance over its defaults as
|
||||
the very first step of `start()`/`startClient()` — before any system boots — so live-reading and
|
||||
boot-caching systems all agree. (Survival Stats stay live-applied; everything else is
|
||||
boot-applied. The footer reminds you.)
|
||||
- **Theme colors** are edited as `R, G, B` (0–255) with a live **swatch**; **fonts** by
|
||||
`Enum.Font` name (the ↻ button cycles common ones; any valid name can be typed). Invalid values
|
||||
are rejected in the form, and a hand-authored bad attribute is **warned about and skipped at
|
||||
boot** — never fatal.
|
||||
- **Reset section** (footer) removes every override on that page in one undo step.
|
||||
- The two Inventory **lists** (equip slots, auto-hotbar categories) are code-managed — set them
|
||||
via `Config.override("Inventory", …)`.
|
||||
- **After re-syncing an UPDATED engine** (one whose config fields/defaults changed), restart
|
||||
Studio — Studio caches required modules per session, so the plugin keeps seeing the old schema
|
||||
until it reloads.
|
||||
|
||||
Resolution order everywhere: **engine default → `Config.override(...)` in your game code → the
|
||||
instance (highest)**. Fields you never touched keep following engine defaults across updates —
|
||||
that's the locked model.
|
||||
|
||||
## Content: authored entries & overrides
|
||||
|
||||
Each category page lists the **merged roster**:
|
||||
|
||||
- **Authored entries** — full defs this plugin created under `SurvivorCoreContent/<Category>`;
|
||||
every field editable, **Delete** removes the def.
|
||||
- **Override entries** (blue *override* pill) — **deltas over a def registered from code** (e.g. a
|
||||
starter kit's `SurvivorCore.Items.register{...}`). Code-registered defs can't be listed here in
|
||||
Edit mode (registries fill at runtime), so: type the id, click **+ Override**, and set **only the
|
||||
fields you want to change** — **blank = inherit** the code value (booleans cycle
|
||||
`(inherit) → true → false`). **Remove** deletes the override and the pristine code def returns on
|
||||
the next Play. A typo'd id can only be caught at runtime: the engine **warns in Output on Play**
|
||||
(`override 'x' matches no registered def`).
|
||||
|
||||
Overrides live under `SurvivorCoreContent/Overrides/<Category>` and are field-merged onto the
|
||||
registered defs at `start()` — before anything caches or replicates, so tooltips, recipes and
|
||||
gatherables all see the merged values. One caveat: on a **code-registered quest** with nested
|
||||
objective/reward tables, the flat `objective*`/`reward*` override fields are ignored (name/
|
||||
description/autoStart/requires/turnIn still merge); overrides of no-code quests merge fully.
|
||||
|
||||
## Why your tuning is "locked" — it survives re-syncs and engine updates
|
||||
|
||||
This is the important part. The engine resolves each stat as **engine default → `Config.override` →
|
||||
the `SurvivalStatsConfig` instance (highest priority)**, and it only ever *seeds* that instance
|
||||
when it's missing — it never overwrites it. The plugin builds on that with a **deltas-only** rule:
|
||||
The engine resolves configuration as **engine default → `Config.override` → your instance
|
||||
(highest priority)**, and it never overwrites your instances. The plugin builds on that with the
|
||||
**deltas-only** rule:
|
||||
|
||||
- It **writes an attribute only when you change a field from the engine default.**
|
||||
- **Resetting** a field (or typing the default back in) **removes** the attribute, so the field goes
|
||||
back to following the engine default.
|
||||
- **Resetting** a field (or typing the default back in) **removes** the attribute, so the field
|
||||
goes back to following the engine default.
|
||||
|
||||
So two good things hold across a SurvivorCore update:
|
||||
|
||||
1. **Your explicit overrides are never lost** — they live on your instance, which the engine never
|
||||
1. **Your explicit overrides are never lost** — they live on your instances, which the engine never
|
||||
overwrites.
|
||||
2. **Fields you didn't touch keep following engine defaults** — so a future release that improves a
|
||||
default rate reaches your game, instead of being frozen at today's value.
|
||||
default reaches your game, instead of being frozen at today's value.
|
||||
|
||||
The plugin can tune **only** the seven owner-facing fields above. It deliberately **cannot** touch
|
||||
`Invert` or `DangerHigh` — those are engine-owned stat *semantics* (which way a bar fills / which end
|
||||
is dangerous); the engine ignores them on the instance, and a guardrail in the plugin makes it
|
||||
impossible to write them.
|
||||
|
||||
> **Caveat — the engine's own demo.** In this repo's demo, `SurvivalStatsConfig` is *Rojo-mounted*,
|
||||
> so a `rojo serve` re-sync reverts plugin edits there. That's a demo artifact: a **real** game seeds
|
||||
> the instance once (install-if-absent, not Rojo-managed), so the plugin's edits persist across engine
|
||||
> updates — which is the whole point.
|
||||
> **Caveat — the engine's own demo.** In this repo's demo, config instances are *Rojo-mounted*, so
|
||||
> a `rojo serve` re-sync reverts plugin edits there. That's a demo artifact: a **real** game keeps
|
||||
> its instances in the place file, so plugin edits persist across engine updates — which is the
|
||||
> whole point.
|
||||
|
||||
---
|
||||
|
||||
See also: [Survival Stats + HUD](survival-stats.md) · [Design Language](design-language.md).
|
||||
See also: [Survival Stats + HUD](survival-stats.md) · [Content authoring](content-authoring.md) ·
|
||||
[Design Language](design-language.md).
|
||||
|
||||
@@ -103,6 +103,35 @@ Open Studio → the **SurvivorCore** toolbar → **Content**. Seven builders:
|
||||
Each edit is one Studio **undo** step. Behind the scenes it creates/edits the
|
||||
`SurvivorCoreContent` instances above, so pressing Play registers your content with no code.
|
||||
|
||||
## Overrides — tune code-registered defs without code
|
||||
|
||||
The authoring folders above can't touch a def that was registered **from code** (the engine's
|
||||
loader skips already-registered ids — code wins). The **`Overrides`** folder can:
|
||||
|
||||
```text
|
||||
SurvivorCoreContent/
|
||||
└─ Overrides (Folder) ← mirrors the category folders above
|
||||
└─ Items (Folder)
|
||||
└─ berry (Configuration) ← name of a CODE-registered def
|
||||
• name = "Sweet Berries" (ONLY the fields you change)
|
||||
• stack = 99
|
||||
```
|
||||
|
||||
At `start()` the engine **field-merges** each override's attributes onto the registered def —
|
||||
before components bind and before the client data caches publish, so tooltips/recipes/gatherables
|
||||
all see the merged values. Rules:
|
||||
|
||||
- **Deltas-only**: an override carries only the attributes it changes; anything absent inherits
|
||||
the code value. Deleting the override restores the pristine code def on the next Play.
|
||||
- The **id can't be renamed** by an override, and an id matching **no registered def warns in
|
||||
Output on Play** (`override 'x' matches no registered def`) — your typo backstop.
|
||||
- **Quest caveat**: a code-registered quest with nested objective/reward tables ignores the flat
|
||||
`objective*`/`reward*` override fields (name/description/autoStart/requires/turnIn still merge).
|
||||
Overrides of no-code quests merge fully.
|
||||
|
||||
The admin plugin's category pages author these for you (**+ Override**, blue pill, blank = inherit)
|
||||
— see [admin-plugin.md](admin-plugin.md).
|
||||
|
||||
## Binding a world object
|
||||
|
||||
A creator builds any mesh, tags it **`Gatherable`** (CollectionService), and sets a `Resource`
|
||||
|
||||
@@ -192,6 +192,15 @@ SurvivorCore.Config.override("Energy", { DrainPerSecond = 10 }) -- your tweak
|
||||
SurvivorCore.Config.get("Energy.DrainPerSecond") -- 10
|
||||
```
|
||||
|
||||
Full resolution order (each layer wins over the previous):
|
||||
|
||||
1. **Engine defaults** — `defineSection` at require time.
|
||||
2. **Your game's `Config.override(...)`** — code-time tuning, any time before `start()`.
|
||||
3. **The `SurvivorCoreEngineConfig` instance** — the no-code layer the
|
||||
[admin plugin's Engine Config editor](admin-plugin.md) writes (deltas-only). Layered over
|
||||
everything as the first step of `start()`/`startClient()`, so it applies on the next Play.
|
||||
(`SurvivalStatsConfig` is its live-applied sibling for survival stats.)
|
||||
|
||||
## `Assets` — keep IDs out of code
|
||||
|
||||
The engine **never** hardcodes asset IDs — your game registers them and the engine reads them
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
ConfigAdmin — headless logic for the Engine Config editor (issue #21). The generic sibling of
|
||||
StatAdmin: it edits the persisted `ReplicatedStorage.SurvivorCoreEngineConfig` instance the
|
||||
engine layers over every Config section at start()/startClient().
|
||||
|
||||
The SCHEMA (sections → groups → typed fields) comes from the ENGINE — readSchema() requires
|
||||
ReplicatedStorage.SurvivorCore.shared.EngineConfig live, so plugin and engine can never
|
||||
disagree about fields, kinds or defaults. Writes are DELTAS-ONLY, same rule as StatAdmin:
|
||||
|
||||
• an attribute exists IFF the owner's value differs from the engine default
|
||||
• typing the default back (or blanking the box) REMOVES the attribute
|
||||
• unset fields keep following engine defaults across engine updates
|
||||
|
||||
Instance shape: <instance>/<Section>/(attributes | <Group>/attributes). Field kinds:
|
||||
number | boolean | string | enum | color3 (native Color3 attribute) | font (attribute is an
|
||||
Enum.Font NAME string; the engine resolves it at apply time).
|
||||
]]
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local ConfigAdmin = {}
|
||||
|
||||
ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig"
|
||||
|
||||
-- Relative float tolerance: |v−d| ≤ EPS·max(|v|,|d|,1e-6). StatAdmin's absolute 1e-4 would
|
||||
-- swallow genuine overrides of tiny rates (Consequences drains ≈ 0.0035/s); the relative form
|
||||
-- still collapses display round-trip noise.
|
||||
local FLOAT_EPSILON = 1e-4
|
||||
|
||||
-- ── Schema (live from the engine) ────────────────────────────────────────────
|
||||
|
||||
local function findEngineConfigModule(): ModuleScript?
|
||||
local engine = ReplicatedStorage:FindFirstChild("SurvivorCore")
|
||||
local shared = engine and engine:FindFirstChild("shared")
|
||||
local module = shared and shared:FindFirstChild("EngineConfig")
|
||||
if module and module:IsA("ModuleScript") then
|
||||
return module
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Read the schema + per-section defaults from the live engine. Never errors:
|
||||
-- { ok = true, source, sections, defaults } | { ok = false, reason, sections = {} }
|
||||
function ConfigAdmin.readSchema(): any
|
||||
local module = findEngineConfigModule()
|
||||
if not module then
|
||||
return {
|
||||
ok = false,
|
||||
reason = "SurvivorCore engine not found in this place (expected"
|
||||
.. " ReplicatedStorage.SurvivorCore.shared.EngineConfig — sync/insert the engine, then Refresh).",
|
||||
sections = {},
|
||||
}
|
||||
end
|
||||
local ok, engineConfig = pcall(require, module)
|
||||
if not ok or typeof(engineConfig) ~= "table" or typeof(engineConfig.SECTIONS) ~= "table" then
|
||||
return {
|
||||
ok = false,
|
||||
reason = "Found the engine, but its EngineConfig module failed to load — is the engine"
|
||||
.. " up to date? (Engine Config needs SurvivorCore ≥ 0.8.)",
|
||||
sections = {},
|
||||
}
|
||||
end
|
||||
local defaults: { [string]: any } = {}
|
||||
for _, section in engineConfig.SECTIONS do
|
||||
defaults[section.id] = engineConfig.getDefaults(section.id)
|
||||
end
|
||||
return {
|
||||
ok = true,
|
||||
source = module:GetFullName(),
|
||||
sections = engineConfig.SECTIONS,
|
||||
defaults = defaults,
|
||||
}
|
||||
end
|
||||
|
||||
-- ── Instance access (read-only vs ensure) ────────────────────────────────────
|
||||
|
||||
function ConfigAdmin.getNode(sectionId: string, groupName: string?): Instance?
|
||||
local instance = ReplicatedStorage:FindFirstChild(ConfigAdmin.INSTANCE_NAME)
|
||||
local section = instance and instance:FindFirstChild(sectionId)
|
||||
if groupName then
|
||||
return section and section:FindFirstChild(groupName)
|
||||
end
|
||||
return section
|
||||
end
|
||||
|
||||
local function ensureConfiguration(parent: Instance, name: string): Instance
|
||||
local existing = parent:FindFirstChild(name)
|
||||
if existing then
|
||||
return existing
|
||||
end
|
||||
local node = Instance.new("Configuration")
|
||||
node.Name = name
|
||||
node.Parent = parent
|
||||
return node
|
||||
end
|
||||
|
||||
function ConfigAdmin.ensureNode(sectionId: string, groupName: string?): Instance
|
||||
local instance = ReplicatedStorage:FindFirstChild(ConfigAdmin.INSTANCE_NAME)
|
||||
if not instance then
|
||||
instance = Instance.new("Configuration")
|
||||
instance.Name = ConfigAdmin.INSTANCE_NAME
|
||||
instance.Parent = ReplicatedStorage
|
||||
end
|
||||
local section = ensureConfiguration(instance, sectionId)
|
||||
if groupName then
|
||||
return ensureConfiguration(section, groupName)
|
||||
end
|
||||
return section
|
||||
end
|
||||
|
||||
-- ── Effective values ─────────────────────────────────────────────────────────
|
||||
|
||||
-- The value the engine will use for a field: the instance override when present, else `default`.
|
||||
function ConfigAdmin.readEffective(sectionId: string, groupName: string?, attr: string, default: any): any
|
||||
local node = ConfigAdmin.getNode(sectionId, groupName)
|
||||
local override = node and node:GetAttribute(attr)
|
||||
if override ~= nil then
|
||||
return { value = override, default = default, hasOverride = true }
|
||||
end
|
||||
return { value = default, default = default, hasOverride = false }
|
||||
end
|
||||
|
||||
-- ── Coercion & default equality ──────────────────────────────────────────────
|
||||
|
||||
-- Parse a raw edit (usually TextBox text) per the field spec. Returns { ok, value } or
|
||||
-- { ok = false, error }. A blank string is handled by setOverride (it means "reset").
|
||||
function ConfigAdmin.coerce(field: any, raw: any): any
|
||||
if field.kind == "number" then
|
||||
local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw))
|
||||
if n == nil then
|
||||
return { ok = false, error = "expected a number" }
|
||||
end
|
||||
if n ~= n or n == math.huge or n == -math.huge then
|
||||
return { ok = false, error = "expected a finite number" }
|
||||
end
|
||||
if field.min then
|
||||
n = math.max(n, field.min)
|
||||
end
|
||||
if field.max then
|
||||
n = math.min(n, field.max)
|
||||
end
|
||||
if field.integer then
|
||||
n = math.floor(n + 0.5)
|
||||
end
|
||||
return { ok = true, value = n }
|
||||
elseif field.kind == "boolean" then
|
||||
if typeof(raw) == "boolean" then
|
||||
return { ok = true, value = raw }
|
||||
end
|
||||
local s = string.lower(tostring(raw))
|
||||
if s == "true" then
|
||||
return { ok = true, value = true }
|
||||
elseif s == "false" then
|
||||
return { ok = true, value = false }
|
||||
end
|
||||
return { ok = false, error = "expected true or false" }
|
||||
elseif field.kind == "enum" then
|
||||
local s = tostring(raw)
|
||||
for _, choice in field.choices or {} :: { string } do
|
||||
if choice == s then
|
||||
return { ok = true, value = s }
|
||||
end
|
||||
end
|
||||
return { ok = false, error = `must be one of: {table.concat(field.choices or {}, ", ")}` }
|
||||
elseif field.kind == "color3" then
|
||||
if typeof(raw) == "Color3" then
|
||||
return { ok = true, value = raw }
|
||||
end
|
||||
local r, g, b = string.match(tostring(raw), "^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$")
|
||||
if not r then
|
||||
return { ok = false, error = "expected R, G, B (0-255 each)" }
|
||||
end
|
||||
local rn, gn, bn = tonumber(r), tonumber(g), tonumber(b)
|
||||
if rn > 255 or gn > 255 or bn > 255 then
|
||||
return { ok = false, error = "channels are 0-255" }
|
||||
end
|
||||
return { ok = true, value = Color3.fromRGB(rn, gn, bn) }
|
||||
elseif field.kind == "font" then
|
||||
local s = tostring(raw)
|
||||
local ok, font = pcall(function()
|
||||
return (Enum.Font :: any)[s]
|
||||
end)
|
||||
if not ok or typeof(font) ~= "EnumItem" then
|
||||
return { ok = false, error = `'{s}' is not an Enum.Font name` }
|
||||
end
|
||||
return { ok = true, value = s } -- stored as the NAME string; the engine resolves it
|
||||
else -- "string" (incl. check = keycode/assetId)
|
||||
local s = tostring(raw)
|
||||
if field.check == "keycode" then
|
||||
local ok, keyCode = pcall(function()
|
||||
return (Enum.KeyCode :: any)[s]
|
||||
end)
|
||||
if not ok or typeof(keyCode) ~= "EnumItem" then
|
||||
return { ok = false, error = `'{s}' is not an Enum.KeyCode name` }
|
||||
end
|
||||
end
|
||||
return { ok = true, value = s }
|
||||
end
|
||||
end
|
||||
|
||||
-- Does a coerced value equal the engine default (→ store nothing)? Kind-aware: fonts compare
|
||||
-- the stored NAME against the default Enum.Font; Color3 compares exactly (both sides come from
|
||||
-- fromRGB construction); numbers use the relative epsilon.
|
||||
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
|
||||
if field.kind == "number" then
|
||||
if typeof(value) ~= "number" or typeof(default) ~= "number" then
|
||||
return value == default
|
||||
end
|
||||
local scale = math.max(math.abs(value), math.abs(default), 1e-6)
|
||||
return math.abs(value - default) <= FLOAT_EPSILON * scale
|
||||
elseif field.kind == "font" then
|
||||
local defaultName = if typeof(default) == "EnumItem" then default.Name else tostring(default)
|
||||
return value == defaultName
|
||||
end
|
||||
return value == default
|
||||
end
|
||||
|
||||
-- Display formatting for effective values/defaults (what the TextBox shows).
|
||||
function ConfigAdmin.formatValue(field: any, value: any): string
|
||||
if value == nil then
|
||||
return ""
|
||||
end
|
||||
if field.kind == "color3" and typeof(value) == "Color3" then
|
||||
return string.format(
|
||||
"%d, %d, %d",
|
||||
math.round(value.R * 255),
|
||||
math.round(value.G * 255),
|
||||
math.round(value.B * 255)
|
||||
)
|
||||
end
|
||||
if field.kind == "font" and typeof(value) == "EnumItem" then
|
||||
return value.Name
|
||||
end
|
||||
return tostring(value)
|
||||
end
|
||||
|
||||
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
|
||||
|
||||
-- Set (or clear) one field. Blank input = reset to default. Writes ONLY when the value differs
|
||||
-- from the engine default; removes the attribute when it matches.
|
||||
-- Returns { ok, action = "write"|"remove"|"noop", error? }.
|
||||
function ConfigAdmin.setOverride(sectionId: string, groupName: string?, field: any, raw: any, default: any): any
|
||||
if typeof(raw) == "string" and string.match(raw, "^%s*$") then
|
||||
return ConfigAdmin.resetOverride(sectionId, groupName, field.attr)
|
||||
end
|
||||
local coerced = ConfigAdmin.coerce(field, raw)
|
||||
if not coerced.ok then
|
||||
return { ok = false, action = "noop", error = coerced.error }
|
||||
end
|
||||
if ConfigAdmin.equalsDefault(field, coerced.value, default) then
|
||||
return ConfigAdmin.resetOverride(sectionId, groupName, field.attr)
|
||||
end
|
||||
local node = ConfigAdmin.ensureNode(sectionId, groupName)
|
||||
node:SetAttribute(field.attr, coerced.value)
|
||||
return { ok = true, action = "write" }
|
||||
end
|
||||
|
||||
function ConfigAdmin.resetOverride(sectionId: string, groupName: string?, attr: string): any
|
||||
local node = ConfigAdmin.getNode(sectionId, groupName)
|
||||
if node and node:GetAttribute(attr) ~= nil then
|
||||
node:SetAttribute(attr, nil)
|
||||
return { ok = true, action = "remove" }
|
||||
end
|
||||
return { ok = true, action = "noop" }
|
||||
end
|
||||
|
||||
-- Remove every override in one section (all groups). Returns the number removed.
|
||||
function ConfigAdmin.resetSection(section: any): number
|
||||
local removed = 0
|
||||
for _, group in section.groups do
|
||||
local node = ConfigAdmin.getNode(section.id, group.name)
|
||||
if node then
|
||||
for _, field in group.fields do
|
||||
if node:GetAttribute(field.attr) ~= nil then
|
||||
node:SetAttribute(field.attr, nil)
|
||||
removed += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return removed
|
||||
end
|
||||
|
||||
-- Count the overrides currently set in one section (drives the "n overridden" footer).
|
||||
function ConfigAdmin.countOverrides(section: any): number
|
||||
local count = 0
|
||||
for _, group in section.groups do
|
||||
local node = ConfigAdmin.getNode(section.id, group.name)
|
||||
if node then
|
||||
for _, field in group.fields do
|
||||
if node:GetAttribute(field.attr) ~= nil then
|
||||
count += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
return ConfigAdmin
|
||||
@@ -0,0 +1,347 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
ConfigAdminUi — the Engine Config editor pages (issue #21). One sidebar page per Config
|
||||
section; each page renders its groups as headed panels of typed field rows (the StatAdminUi
|
||||
row pattern, generalized): reset dot ●/○, label, and a control per kind — number/string
|
||||
TextBox (placeholder = engine default), boolean/enum click-cycle, color3 TextBox "R, G, B"
|
||||
with a live swatch, font TextBox with a cycle button over common fonts. All writes route
|
||||
through the plugin main's record() wrappers (one undo step each), deltas-only via ConfigAdmin.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local ConfigAdminUi = {}
|
||||
|
||||
local ROW_H = Theme.ROW_H
|
||||
local FOOTER_H = 36
|
||||
|
||||
-- The click-cycle list for font fields (any valid Enum.Font name can still be typed).
|
||||
local COMMON_FONTS = {
|
||||
"Gotham",
|
||||
"GothamMedium",
|
||||
"GothamBold",
|
||||
"GothamBlack",
|
||||
"SourceSans",
|
||||
"SourceSansBold",
|
||||
"Arial",
|
||||
"ArialBold",
|
||||
"Merriweather",
|
||||
"Bangers",
|
||||
"PermanentMarker",
|
||||
}
|
||||
|
||||
-- One field row. `groupName` may be nil (root attributes).
|
||||
local function buildRow(ConfigAdmin, sectionId, groupName, field, eff, applyEdit, applyReset): Instance
|
||||
local row = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, ROW_H),
|
||||
BackgroundTransparency = 1,
|
||||
})
|
||||
|
||||
local reset = Theme.button({
|
||||
Size = UDim2.fromOffset(18, 18),
|
||||
Position = UDim2.new(0, 0, 0.5, -9),
|
||||
Text = eff.hasOverride and "●" or "○",
|
||||
TextColor3 = eff.hasOverride and Theme.COLOR.ACCENT or Theme.COLOR.DIM,
|
||||
})
|
||||
reset.Parent = row
|
||||
reset.MouseButton1Click:Connect(function()
|
||||
applyReset(sectionId, groupName, field.attr)
|
||||
end)
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.new(0.42, -28, 1, 0),
|
||||
Position = UDim2.fromOffset(26, 0),
|
||||
Text = field.label,
|
||||
TextColor3 = eff.hasOverride and Theme.COLOR.TEXT or Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
row
|
||||
|
||||
local controlPos = UDim2.new(0.42, 4, 0, 2)
|
||||
local controlSize = UDim2.new(0.58, -4, 0, ROW_H - 4)
|
||||
|
||||
if field.kind == "boolean" or field.kind == "enum" then
|
||||
local control = Theme.button({
|
||||
Size = controlSize,
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
TextSize = 13,
|
||||
})
|
||||
control.Parent = row
|
||||
control.MouseButton1Click:Connect(function()
|
||||
local nextValue: any
|
||||
if field.kind == "boolean" then
|
||||
nextValue = not (eff.value == true)
|
||||
else
|
||||
local choices = field.choices or {}
|
||||
local i = table.find(choices, tostring(eff.value)) or 0
|
||||
nextValue = choices[(i % #choices) + 1]
|
||||
end
|
||||
applyEdit(sectionId, groupName, field, nextValue, eff.default)
|
||||
end)
|
||||
elseif field.kind == "color3" then
|
||||
-- [ R, G, B text box ][ swatch ] — the swatch previews the effective color live.
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.58, -28, 0, ROW_H - 4),
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
|
||||
})
|
||||
box.Parent = row
|
||||
local swatch = Theme.make("Frame", {
|
||||
Size = UDim2.fromOffset(18, 18),
|
||||
Position = UDim2.new(1, -18, 0.5, -9),
|
||||
BackgroundColor3 = if typeof(eff.value) == "Color3" then eff.value else Color3.new(0, 0, 0),
|
||||
BorderSizePixel = 0,
|
||||
}, { Theme.corner(4), Theme.stroke(0.7) })
|
||||
swatch.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applyEdit(sectionId, groupName, field, box.Text, eff.default)
|
||||
end)
|
||||
elseif field.kind == "font" then
|
||||
-- [ font name box ][ ↻ cycle ] — cycles common fonts; any Enum.Font name can be typed.
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.58, -28, 0, ROW_H - 4),
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applyEdit(sectionId, groupName, field, box.Text, eff.default)
|
||||
end)
|
||||
local cycle = Theme.button({
|
||||
Size = UDim2.fromOffset(22, ROW_H - 4),
|
||||
Position = UDim2.new(1, -22, 0, 2),
|
||||
Text = "↻",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
})
|
||||
cycle.Parent = row
|
||||
cycle.MouseButton1Click:Connect(function()
|
||||
local current = ConfigAdmin.formatValue(field, eff.value)
|
||||
local i = table.find(COMMON_FONTS, current) or 0
|
||||
applyEdit(sectionId, groupName, field, COMMON_FONTS[(i % #COMMON_FONTS) + 1], eff.default)
|
||||
end)
|
||||
else
|
||||
local box = Theme.textBox({
|
||||
Size = controlSize,
|
||||
Position = controlPos,
|
||||
Text = ConfigAdmin.formatValue(field, eff.value),
|
||||
PlaceholderText = ConfigAdmin.formatValue(field, eff.default),
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applyEdit(sectionId, groupName, field, box.Text, eff.default)
|
||||
end)
|
||||
end
|
||||
|
||||
return row
|
||||
end
|
||||
|
||||
-- Mount one section's page. Re-reads the schema on every refresh (self-heals after re-syncs).
|
||||
local function mountSection(
|
||||
container: Frame,
|
||||
ConfigAdmin: any,
|
||||
sectionId: string,
|
||||
applyEdit,
|
||||
applyReset,
|
||||
applyResetSection
|
||||
): { refresh: () -> () }
|
||||
local header = Theme.header(container, "Engine Config")
|
||||
local scroll = Theme.scroll(32, FOOTER_H, 6)
|
||||
scroll.Parent = container
|
||||
|
||||
local footer = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, FOOTER_H),
|
||||
Position = UDim2.new(0, 0, 1, -FOOTER_H),
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
BorderSizePixel = 0,
|
||||
})
|
||||
footer.Parent = container
|
||||
local resetBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(96, 22),
|
||||
Position = UDim2.new(0, 10, 0.5, -11),
|
||||
Text = "Reset section",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
})
|
||||
resetBtn.Parent = footer
|
||||
local status = Theme.label({
|
||||
Size = UDim2.new(1, -120, 1, 0),
|
||||
Position = UDim2.fromOffset(112, 0),
|
||||
Text = "",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Right,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
TextSize = 11,
|
||||
})
|
||||
status.Parent = footer
|
||||
|
||||
local currentSection: any = nil
|
||||
|
||||
-- Route a rejected edit's reason to the footer (set AFTER record()'s refresh, so it sticks).
|
||||
local function editField(editSectionId, groupName, field, raw, default)
|
||||
local res = applyEdit(editSectionId, groupName, field, raw, default)
|
||||
if typeof(res) == "table" and res.ok == false and res.error then
|
||||
status.Text = `✗ {field.label}: {res.error}`
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
local schema = ConfigAdmin.readSchema()
|
||||
if not schema.ok then
|
||||
currentSection = nil
|
||||
status.Text = ""
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 80),
|
||||
Text = schema.reason or "Engine not found.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
return
|
||||
end
|
||||
|
||||
local section: any = nil
|
||||
for _, s in schema.sections do
|
||||
if s.id == sectionId then
|
||||
section = s
|
||||
break
|
||||
end
|
||||
end
|
||||
if not section then
|
||||
status.Text = ""
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 60),
|
||||
Text = `Section '{sectionId}' is not in this engine's schema (older engine?).`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
return
|
||||
end
|
||||
currentSection = section
|
||||
local defaults = schema.defaults[sectionId] or {}
|
||||
|
||||
local order = 0
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = section.title,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 15,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
|
||||
if section.note then
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = section.note,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
end
|
||||
|
||||
for _, group in section.groups do
|
||||
order += 1
|
||||
-- Theme.panel() auto-sizes (AutomaticSize.Y) — a fixed-height formula here would
|
||||
-- miss the panel's own padding + per-row list gaps and clip the last row.
|
||||
local panel = Theme.panel()
|
||||
panel.LayoutOrder = order
|
||||
panel.Parent = scroll
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = group.label,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
panel
|
||||
|
||||
local groupDefaults = if group.name then defaults[group.name] else defaults
|
||||
for i, field in group.fields do
|
||||
local default = if typeof(groupDefaults) == "table" then groupDefaults[field.attr] else nil
|
||||
local eff = ConfigAdmin.readEffective(section.id, group.name, field.attr, default)
|
||||
local row = buildRow(ConfigAdmin, section.id, group.name, field, eff, editField, applyReset)
|
||||
row.LayoutOrder = i
|
||||
row.Parent = panel
|
||||
end
|
||||
end
|
||||
|
||||
local overridden = ConfigAdmin.countOverrides(section)
|
||||
status.Text = if overridden == 0
|
||||
then "no overrides — engine defaults"
|
||||
else `{overridden} field(s) overridden · applies on next Play`
|
||||
end
|
||||
|
||||
resetBtn.MouseButton1Click:Connect(function()
|
||||
if currentSection then
|
||||
applyResetSection(currentSection)
|
||||
end
|
||||
end)
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
-- Build the Nav pages: one per section when the engine is present, else a single explainer page
|
||||
-- whose Refresh triggers `onEngineFound` (the plugin main rebuilds the whole window) once the
|
||||
-- engine appears — the page list is otherwise fixed for the life of the mount.
|
||||
function ConfigAdminUi.pages(ConfigAdmin: any, applyEdit, applyReset, applyResetSection, onEngineFound): { any }
|
||||
local pages = {}
|
||||
local schema = ConfigAdmin.readSchema()
|
||||
if schema.ok then
|
||||
for _, section in schema.sections do
|
||||
table.insert(pages, {
|
||||
id = "config/" .. section.id,
|
||||
label = section.title,
|
||||
section = "Engine Config",
|
||||
mount = function(frame)
|
||||
return mountSection(frame, ConfigAdmin, section.id, applyEdit, applyReset, applyResetSection)
|
||||
end,
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(pages, {
|
||||
id = "config/unavailable",
|
||||
label = "Engine Config",
|
||||
section = "Engine Config",
|
||||
mount = function(frame)
|
||||
local header = Theme.header(frame, "Engine Config")
|
||||
local message = Theme.label({
|
||||
Size = UDim2.new(1, -24, 0, 100),
|
||||
Position = UDim2.fromOffset(12, 40),
|
||||
Text = (schema.reason or "Engine not found.")
|
||||
.. " Once the engine is in the place, click Refresh to load the sections.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextYAlignment = Enum.TextYAlignment.Top,
|
||||
})
|
||||
message.Parent = frame
|
||||
local function refresh()
|
||||
local retry = ConfigAdmin.readSchema()
|
||||
if retry.ok and onEngineFound then
|
||||
onEngineFound() -- the plugin main remounts the window with the full page list
|
||||
end
|
||||
end
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
return { refresh = refresh }
|
||||
end,
|
||||
})
|
||||
end
|
||||
return pages
|
||||
end
|
||||
|
||||
return ConfigAdminUi
|
||||
@@ -337,6 +337,10 @@ function ContentAdmin.create(catKey: string, rawId: any): (boolean, string)
|
||||
if folder:FindFirstChild(id) then
|
||||
return false, `'{id}' already exists.`
|
||||
end
|
||||
if ContentAdmin.getOverrideNode(catKey, id) then
|
||||
-- Authoring over an override would leave ONE roster entry masking the other.
|
||||
return false, `'{id}' has an override — remove the override first (or keep editing it).`
|
||||
end
|
||||
local node = Instance.new("Configuration")
|
||||
node.Name = id
|
||||
for _, f in cat.fields do
|
||||
@@ -377,4 +381,131 @@ function ContentAdmin.set(catKey: string, id: string, field: FieldSpec, raw: any
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- ── Overrides (issue #40) ─────────────────────────────────────────────────────
|
||||
--
|
||||
-- SurvivorCoreContent/Overrides/<folder>/<id> tunes a def registered FROM CODE (which the
|
||||
-- authoring folders can't touch — the engine's loadFromFolder skips already-registered keys).
|
||||
-- The engine field-merges these at start(), so an override child carries ONLY the attributes it
|
||||
-- changes: BLANK = INHERIT the code value. The plugin can't display code defaults in Edit mode
|
||||
-- (registries fill at runtime), which is exactly why these are deltas with empty placeholders —
|
||||
-- and why an unknown id can only be caught at Play (the engine warns in Output).
|
||||
|
||||
ContentAdmin.OVERRIDES_FOLDER = "Overrides"
|
||||
|
||||
function ContentAdmin.getOverrideFolder(catKey: string): Instance?
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local root = ContentAdmin.getRoot()
|
||||
local overrides = root and root:FindFirstChild(ContentAdmin.OVERRIDES_FOLDER)
|
||||
return overrides and cat and overrides:FindFirstChild(cat.folder) or nil
|
||||
end
|
||||
|
||||
-- Find-or-create SurvivorCoreContent/Overrides/<folder>. Only called from the write path.
|
||||
function ContentAdmin.ensureOverrideFolder(catKey: string): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
ContentAdmin.ensureFolder(catKey) -- guarantees the root exists
|
||||
local root = ContentAdmin.getRoot() :: Instance
|
||||
local overrides = root:FindFirstChild(ContentAdmin.OVERRIDES_FOLDER)
|
||||
if not overrides then
|
||||
overrides = Instance.new("Folder")
|
||||
overrides.Name = ContentAdmin.OVERRIDES_FOLDER
|
||||
overrides.Parent = root
|
||||
end
|
||||
local folder = overrides:FindFirstChild(cat.folder)
|
||||
if not folder then
|
||||
folder = Instance.new("Folder")
|
||||
folder.Name = cat.folder
|
||||
folder.Parent = overrides
|
||||
end
|
||||
return folder
|
||||
end
|
||||
|
||||
function ContentAdmin.getOverrideNode(catKey: string, id: string): Instance?
|
||||
local folder = ContentAdmin.getOverrideFolder(catKey)
|
||||
return folder and folder:FindFirstChild(id) or nil
|
||||
end
|
||||
|
||||
export type RosterEntry = { id: string, authored: boolean, override: boolean }
|
||||
|
||||
-- The merged category roster: authored entries + override entries, sorted by id.
|
||||
function ContentAdmin.listRoster(catKey: string): { RosterEntry }
|
||||
local byId: { [string]: RosterEntry } = {}
|
||||
for _, id in ContentAdmin.list(catKey) do
|
||||
byId[id] = { id = id, authored = true, override = false }
|
||||
end
|
||||
local overrideFolder = ContentAdmin.getOverrideFolder(catKey)
|
||||
if overrideFolder then
|
||||
for _, child in overrideFolder:GetChildren() do
|
||||
local entry = byId[child.Name]
|
||||
if entry then
|
||||
entry.override = true -- authored + override shouldn't happen (createOverride refuses)
|
||||
else
|
||||
byId[child.Name] = { id = child.Name, authored = false, override = true }
|
||||
end
|
||||
end
|
||||
end
|
||||
local out = {}
|
||||
for _, entry in byId do
|
||||
table.insert(out, entry)
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
return a.id < b.id
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Create an EMPTY override (no attributes — deltas-only; NOT create()'s seed-all-defaults).
|
||||
function ContentAdmin.createOverride(catKey: string, rawId: any): (boolean, string)
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
if not cat then
|
||||
return false, "unknown category"
|
||||
end
|
||||
local id = ContentAdmin.sanitizeId(rawId)
|
||||
if id == "" then
|
||||
return false, "Enter a valid id (letters, numbers, _)."
|
||||
end
|
||||
if ContentAdmin.getNode(catKey, id) then
|
||||
return false, `'{id}' is authored here — edit the entry itself.`
|
||||
end
|
||||
local folder = ContentAdmin.ensureOverrideFolder(catKey)
|
||||
if folder:FindFirstChild(id) then
|
||||
return false, `'{id}' already has an override.`
|
||||
end
|
||||
local node = Instance.new("Configuration")
|
||||
node.Name = id
|
||||
node.Parent = folder
|
||||
return true, id
|
||||
end
|
||||
|
||||
function ContentAdmin.deleteOverride(catKey: string, id: string)
|
||||
local node = ContentAdmin.getOverrideNode(catKey, id)
|
||||
if node then
|
||||
node:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- Read one override field: { value?, hasOverride } — value is nil when inheriting.
|
||||
function ContentAdmin.readOverrideField(catKey: string, id: string, field: FieldSpec): any
|
||||
local node = ContentAdmin.getOverrideNode(catKey, id)
|
||||
local v = node and node:GetAttribute(field.attr)
|
||||
return { value = v, hasOverride = v ~= nil }
|
||||
end
|
||||
|
||||
-- Write one override field. BLANK (or nil) = remove the attribute → inherit the code value.
|
||||
function ContentAdmin.setOverrideField(catKey: string, id: string, field: FieldSpec, raw: any): (boolean, string?)
|
||||
local node = ContentAdmin.getOverrideNode(catKey, id)
|
||||
if not node then
|
||||
return false, "missing override"
|
||||
end
|
||||
if raw == nil or (typeof(raw) == "string" and string.match(raw, "^%s*$")) then
|
||||
node:SetAttribute(field.attr, nil)
|
||||
return true, nil
|
||||
end
|
||||
local ok, value, err = coerce(field.kind, raw)
|
||||
if not ok then
|
||||
return false, err
|
||||
end
|
||||
node:SetAttribute(field.attr, value)
|
||||
return true, nil
|
||||
end
|
||||
|
||||
return ContentAdmin
|
||||
|
||||
+221
-244
@@ -1,154 +1,166 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
ContentAdminUi — the dock-widget UI for the no-code content builder. A thin layer over
|
||||
ContentAdmin (the logic): it renders an Items section and a Gatherables section, each with a
|
||||
"create" row and one editable panel per entry (field rows + Delete). Edits route through the
|
||||
applySet / applyCreate / applyDelete callbacks the plugin main supplies (which wrap the
|
||||
ContentAdmin call in a ChangeHistoryService recording, then refresh). Styling matches
|
||||
StatAdminUi / docs/design-language.md.
|
||||
ContentAdminUi — the no-code content editor pages. One sidebar page per category
|
||||
(mountCategory), each rendering the MERGED roster (issue #40):
|
||||
|
||||
• authored entries — Configurations under SurvivorCoreContent/<folder>: the full editor
|
||||
(every field seeded, Delete destroys the def), exactly as before.
|
||||
• override entries — Configurations under SurvivorCoreContent/Overrides/<folder>: DELTAS
|
||||
over a def registered from code. Blank = inherit the code value (the plugin can't show
|
||||
code defaults in Edit mode — registries fill at runtime); Remove restores the pristine
|
||||
code def on the next Play.
|
||||
|
||||
buildRosterEntry/matchEntry are exported so the global search page renders the same editable
|
||||
panels. All writes route through the `actions` table of record()-wrapped callbacks the plugin
|
||||
main supplies. Styling comes from Theme.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local ContentAdminUi = {}
|
||||
|
||||
local COL_BG = Color3.fromRGB(18, 21, 28)
|
||||
local COL_PANEL = Color3.fromRGB(28, 32, 42)
|
||||
local COL_TEXT = Color3.fromRGB(235, 238, 245)
|
||||
local COL_DIM = Color3.fromRGB(150, 160, 180)
|
||||
local COL_FIELD = Color3.fromRGB(38, 43, 56)
|
||||
local COL_ACCENT = Color3.fromRGB(120, 170, 255)
|
||||
local COL_DANGER = Color3.fromRGB(210, 90, 90)
|
||||
local FONT = Enum.Font.GothamMedium
|
||||
local ROW_H = 26
|
||||
local ROW_H = Theme.ROW_H
|
||||
|
||||
local function make(class: string, props: { [string]: any }, children: { Instance }?): Instance
|
||||
local inst = Instance.new(class)
|
||||
for key, value in props do
|
||||
(inst :: any)[key] = value
|
||||
end
|
||||
if children then
|
||||
for _, child in children do
|
||||
child.Parent = inst
|
||||
end
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
local function corner(radius: number): Instance
|
||||
return make("UICorner", { CornerRadius = UDim.new(0, radius) })
|
||||
end
|
||||
|
||||
-- A single field row: label .... control (TextBox committed on focus loss).
|
||||
local function buildField(catKey: string, id: string, field: any, value: any, applySet): Instance
|
||||
local row = make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
|
||||
|
||||
make("TextLabel", {
|
||||
-- A single authored-field row: label .... TextBox (committed on focus loss).
|
||||
local function buildField(catKey: string, id: string, field: any, value: any, actions): Instance
|
||||
local row = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(0.4, 1),
|
||||
BackgroundTransparency = 1,
|
||||
Text = field.label,
|
||||
TextColor3 = COL_DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
row
|
||||
|
||||
local box = make("TextBox", {
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
|
||||
Position = UDim2.new(0.4, 4, 0, 2),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
Text = tostring(value),
|
||||
PlaceholderText = field.placeholder or tostring(field.default),
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
ClearTextOnFocus = false,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
applySet(catKey, id, field, box.Text)
|
||||
actions.set(catKey, id, field, box.Text)
|
||||
end)
|
||||
|
||||
return row
|
||||
end
|
||||
|
||||
-- One entry panel: a title row (id + Delete) + a field row per field.
|
||||
local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySet, applyDelete, applySpawn): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local panel = make("Frame", {
|
||||
BackgroundColor3 = COL_PANEL,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
corner(8),
|
||||
make("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 8),
|
||||
PaddingRight = UDim.new(0, 8),
|
||||
PaddingTop = UDim.new(0, 6),
|
||||
PaddingBottom = UDim.new(0, 8),
|
||||
}),
|
||||
make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
})
|
||||
-- A single override-field row: blank = inherit. Booleans cycle (inherit) → true → false.
|
||||
local function buildOverrideField(catKey: string, id: string, field: any, state: any, actions): Instance
|
||||
local row = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, ROW_H), BackgroundTransparency = 1 })
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(0.4, 1),
|
||||
Text = field.label,
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
row
|
||||
|
||||
local title = make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
|
||||
if field.kind == "boolean" then
|
||||
local control = Theme.button({
|
||||
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
|
||||
Position = UDim2.new(0.4, 4, 0, 2),
|
||||
Text = if state.hasOverride then tostring(state.value) else "(inherit)",
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
TextSize = 13,
|
||||
})
|
||||
control.Parent = row
|
||||
control.MouseButton1Click:Connect(function()
|
||||
-- (inherit) → true → false → (inherit)
|
||||
local nextRaw: any
|
||||
if not state.hasOverride then
|
||||
nextRaw = true
|
||||
elseif state.value == true then
|
||||
nextRaw = false
|
||||
else
|
||||
nextRaw = "" -- blank = remove the attribute = inherit
|
||||
end
|
||||
actions.setOverride(catKey, id, field, nextRaw)
|
||||
end)
|
||||
else
|
||||
local box = Theme.textBox({
|
||||
Size = UDim2.new(0.6, -4, 0, ROW_H - 4),
|
||||
Position = UDim2.new(0.4, 4, 0, 2),
|
||||
Text = if state.hasOverride then tostring(state.value) else "",
|
||||
PlaceholderText = "(code default)",
|
||||
})
|
||||
box.Parent = row
|
||||
box.FocusLost:Connect(function()
|
||||
actions.setOverride(catKey, id, field, box.Text)
|
||||
end)
|
||||
end
|
||||
return row
|
||||
end
|
||||
|
||||
-- One roster entry panel — branches authored vs override.
|
||||
function ContentAdminUi.buildRosterEntry(catKey: string, entry: any, ContentAdmin: any, actions): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local panel = Theme.panel()
|
||||
local title = Theme.make("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1, LayoutOrder = 0 })
|
||||
title.Parent = panel
|
||||
|
||||
local del = make("TextButton", {
|
||||
local del = Theme.button({
|
||||
Size = UDim2.fromOffset(56, 20),
|
||||
Position = UDim2.new(1, -56, 0.5, -10),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
AutoButtonColor = true,
|
||||
Text = "Delete",
|
||||
TextColor3 = COL_DANGER,
|
||||
TextSize = 12,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
Text = if entry.override then "Remove" else "Delete",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
})
|
||||
del.Parent = title
|
||||
del.MouseButton1Click:Connect(function()
|
||||
applyDelete(catKey, id)
|
||||
if entry.override then
|
||||
actions.deleteOverride(catKey, entry.id)
|
||||
else
|
||||
actions.delete(catKey, entry.id)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter Tool
|
||||
-- (Handle + the weapon's ToolType/WeaponKind) you can model the look on — all in front of the
|
||||
-- camera, the bridge between the editor and the world.
|
||||
local labelRight = 64
|
||||
if (catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and applySpawn then
|
||||
local add = make("TextButton", {
|
||||
|
||||
if entry.override then
|
||||
-- Accent pill marking this as a delta over a code-registered def.
|
||||
local pill = Theme.badge("override", Theme.COLOR.ACCENT)
|
||||
pill.AnchorPoint = Vector2.new(1, 0.5)
|
||||
pill.Position = UDim2.new(1, -64, 0.5, 0)
|
||||
pill.Parent = title
|
||||
labelRight = 128
|
||||
elseif
|
||||
(catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons" or catKey == "Quests") and actions.spawn
|
||||
then
|
||||
-- Gatherables + Mobs + Quests drop a tagged, def-linked instance; Weapons drop a starter
|
||||
-- Tool you can model the look on — all in front of the camera.
|
||||
local add = Theme.button({
|
||||
Size = UDim2.fromOffset(104, 20),
|
||||
Position = UDim2.new(1, -168, 0.5, -10),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
AutoButtonColor = true,
|
||||
Text = if catKey == "Weapons"
|
||||
then "+ Tool model"
|
||||
elseif catKey == "Quests" then "+ Quest giver"
|
||||
else "+ Add to World",
|
||||
TextColor3 = COL_ACCENT,
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 11,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
})
|
||||
add.Parent = title
|
||||
add.MouseButton1Click:Connect(function()
|
||||
applySpawn(catKey, id)
|
||||
actions.spawn(catKey, entry.id)
|
||||
end)
|
||||
labelRight = 176
|
||||
end
|
||||
|
||||
make("TextLabel", {
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, -labelRight, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = id,
|
||||
TextColor3 = COL_TEXT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Text = entry.id,
|
||||
TextSize = 14,
|
||||
Font = Enum.Font.GothamBold,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
title
|
||||
|
||||
for i, field in cat.fields do
|
||||
local row = buildField(catKey, id, field, ContentAdmin.read(catKey, id, field), applySet)
|
||||
local row: any
|
||||
if entry.override then
|
||||
local state = ContentAdmin.readOverrideField(catKey, entry.id, field)
|
||||
row = buildOverrideField(catKey, entry.id, field, state, actions)
|
||||
else
|
||||
row = buildField(catKey, entry.id, field, ContentAdmin.read(catKey, entry.id, field), actions)
|
||||
end
|
||||
row.LayoutOrder = i
|
||||
row.Parent = panel
|
||||
end
|
||||
@@ -156,165 +168,130 @@ local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySe
|
||||
return panel
|
||||
end
|
||||
|
||||
-- Build one category block: bold header, a create row, then an entry panel per id.
|
||||
local function buildCategory(
|
||||
catKey: string,
|
||||
ContentAdmin: any,
|
||||
applySet,
|
||||
applyCreate,
|
||||
applyDelete,
|
||||
applySpawn
|
||||
): Instance
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local block = make("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
make("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
})
|
||||
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
BackgroundTransparency = 1,
|
||||
Text = cat.title,
|
||||
TextColor3 = COL_ACCENT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 15,
|
||||
Font = Enum.Font.GothamBold,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
block
|
||||
|
||||
-- Create row: [ new id ............ ] [ + Create ]
|
||||
local createRow = make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
|
||||
createRow.Parent = block
|
||||
local idBox = make("TextBox", {
|
||||
Size = UDim2.new(1, -92, 1, 0),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
Text = "",
|
||||
PlaceholderText = cat.keyLabel,
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
ClearTextOnFocus = false,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
|
||||
idBox.Parent = createRow
|
||||
local createBtn = make("TextButton", {
|
||||
Size = UDim2.fromOffset(84, 28),
|
||||
Position = UDim2.new(1, -84, 0, 0),
|
||||
BackgroundColor3 = COL_FIELD,
|
||||
AutoButtonColor = true,
|
||||
Text = "+ Create",
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 13,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
createBtn.Parent = createRow
|
||||
createBtn.MouseButton1Click:Connect(function()
|
||||
applyCreate(catKey, idBox.Text)
|
||||
end)
|
||||
|
||||
local ids = ContentAdmin.list(catKey)
|
||||
if #ids == 0 then
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
BackgroundTransparency = 1,
|
||||
Text = `No {string.lower(cat.title)} yet — create one above.`,
|
||||
TextColor3 = COL_DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 12,
|
||||
Font = FONT,
|
||||
LayoutOrder = 2,
|
||||
}).Parent =
|
||||
block
|
||||
else
|
||||
for i, id in ids do
|
||||
local entry = buildEntry(catKey, id, ContentAdmin, applySet, applyDelete, applySpawn)
|
||||
entry.LayoutOrder = 2 + i
|
||||
entry.Parent = block
|
||||
end
|
||||
-- Case-insensitive substring match on the entry id and its `name` attribute (when the category
|
||||
-- has one). Shared by the category pages and the global search page.
|
||||
function ContentAdminUi.matchEntry(ContentAdmin: any, catKey: string, entry: any, loweredQuery: string): boolean
|
||||
if string.find(string.lower(entry.id), loweredQuery, 1, true) then
|
||||
return true
|
||||
end
|
||||
|
||||
return block
|
||||
local node = if entry.override
|
||||
then ContentAdmin.getOverrideNode(catKey, entry.id)
|
||||
else ContentAdmin.getNode(catKey, entry.id)
|
||||
local name = node and node:GetAttribute("name")
|
||||
if typeof(name) == "string" and string.find(string.lower(name), loweredQuery, 1, true) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function ContentAdminUi.mount(
|
||||
container: Instance,
|
||||
-- Mount one category's page. `actions` = { set, create, delete, spawn, createOverride,
|
||||
-- setOverride, deleteOverride } — all record()-wrapped by the plugin main.
|
||||
function ContentAdminUi.mountCategory(
|
||||
container: Frame,
|
||||
catKey: string,
|
||||
ContentAdmin: any,
|
||||
applySet,
|
||||
applyCreate,
|
||||
applyDelete,
|
||||
applySpawn
|
||||
actions
|
||||
): { refresh: () -> () }
|
||||
for _, child in container:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
(container :: any).BackgroundColor3 = COL_BG
|
||||
|
||||
local header = make("Frame", { Size = UDim2.new(1, 0, 0, 32), BackgroundTransparency = 1 })
|
||||
header.Parent = container
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, -84, 1, 0),
|
||||
Position = UDim2.fromOffset(12, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "Content",
|
||||
TextColor3 = COL_TEXT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 15,
|
||||
Font = Enum.Font.GothamBold,
|
||||
}).Parent =
|
||||
header
|
||||
local refreshBtn = make("TextButton", {
|
||||
Size = UDim2.fromOffset(72, 22),
|
||||
Position = UDim2.new(1, -80, 0.5, -11),
|
||||
BackgroundColor3 = COL_PANEL,
|
||||
AutoButtonColor = true,
|
||||
Text = "Refresh",
|
||||
TextColor3 = COL_TEXT,
|
||||
TextSize = 12,
|
||||
Font = FONT,
|
||||
BorderSizePixel = 0,
|
||||
}, { corner(4) })
|
||||
refreshBtn.Parent = header
|
||||
|
||||
local scroll = make("ScrollingFrame", {
|
||||
Size = UDim2.new(1, 0, 1, -32),
|
||||
Position = UDim2.fromOffset(0, 32),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 6,
|
||||
CanvasSize = UDim2.new(),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
make("UIListLayout", { Padding = UDim.new(0, 12), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
make("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 10),
|
||||
PaddingRight = UDim.new(0, 10),
|
||||
PaddingTop = UDim.new(0, 6),
|
||||
PaddingBottom = UDim.new(0, 12),
|
||||
}),
|
||||
})
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
local header = Theme.header(container, cat.title)
|
||||
local scroll = Theme.scroll(32, 0, 8)
|
||||
scroll.Parent = container
|
||||
|
||||
-- A failed Create/Override's reason, rendered by refresh() under the create row (the row
|
||||
-- itself is destroyed and rebuilt by record()'s refresh, so the message must live here).
|
||||
local lastError: string? = nil
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
for i, catKey in ContentAdmin.ORDER do
|
||||
local block = buildCategory(catKey, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
|
||||
block.LayoutOrder = i
|
||||
block.Parent = scroll
|
||||
|
||||
-- Create row: [ new id ............ ] [ + Create ] [ + Override ]
|
||||
local createRow =
|
||||
Theme.make("Frame", { Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, LayoutOrder = 1 })
|
||||
createRow.Parent = scroll
|
||||
local idBox = Theme.textBox({
|
||||
Size = UDim2.new(1, -172, 1, 0),
|
||||
Text = "",
|
||||
PlaceholderText = cat.keyLabel,
|
||||
})
|
||||
idBox.Parent = createRow
|
||||
local createBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(76, 28),
|
||||
Position = UDim2.new(1, -164, 0, 0),
|
||||
Text = "+ Create",
|
||||
TextSize = 13,
|
||||
})
|
||||
createBtn.Parent = createRow
|
||||
createBtn.MouseButton1Click:Connect(function()
|
||||
local res = actions.create(catKey, idBox.Text)
|
||||
lastError = if typeof(res) == "table" and res.ok == false then res.message else nil
|
||||
refresh()
|
||||
end)
|
||||
local overrideBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(84, 28),
|
||||
Position = UDim2.new(1, -84, 0, 0),
|
||||
Text = "+ Override",
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 13,
|
||||
})
|
||||
overrideBtn.Parent = createRow
|
||||
overrideBtn.MouseButton1Click:Connect(function()
|
||||
local res = actions.createOverride(catKey, idBox.Text)
|
||||
lastError = if typeof(res) == "table" and res.ok == false then res.message else nil
|
||||
refresh()
|
||||
end)
|
||||
|
||||
if lastError then
|
||||
local message = lastError :: string
|
||||
lastError = nil -- shown once; the next refresh clears it
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = `✗ {message}`,
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 2,
|
||||
}).Parent =
|
||||
scroll
|
||||
end
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = "Create authors a full def here. Override tunes a def registered from code"
|
||||
.. " (invisible in Edit — registries fill at runtime): type its id, set ONLY the"
|
||||
.. " fields to change, blank = inherit. Typos warn in Output on Play.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 11,
|
||||
LayoutOrder = 3,
|
||||
}).Parent =
|
||||
scroll
|
||||
|
||||
local roster = ContentAdmin.listRoster(catKey)
|
||||
if #roster == 0 then
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = `No {string.lower(cat.title)} yet — create one above.`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 4,
|
||||
}).Parent =
|
||||
scroll
|
||||
else
|
||||
for i, entry in roster do
|
||||
local panel = ContentAdminUi.buildRosterEntry(catKey, entry, ContentAdmin, actions)
|
||||
panel.LayoutOrder = 4 + i
|
||||
panel.Parent = scroll
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Nav — the plugin's sidebar rail + page router. Pages are data (id/label/section/mount);
|
||||
Nav renders the grouped rail (uppercase section headers, count badges), lazy-mounts each
|
||||
page into its own detail frame on first selection, and toggles visibility after that —
|
||||
so per-page state (scroll position, footer status) survives switching.
|
||||
|
||||
refreshAll() refreshes the ACTIVE page immediately and dirty-flags the other mounted pages,
|
||||
which re-render the next time they're shown — an edit on one page never rebuilds a form the
|
||||
user might be mid-thought on elsewhere, but every page is fresh by the time it's seen.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local Nav = {}
|
||||
|
||||
export type PageHandle = { refresh: () -> () }
|
||||
|
||||
export type Page = {
|
||||
id: string,
|
||||
label: string,
|
||||
section: string, -- rail group header ("" = top-level, no header)
|
||||
badge: (() -> number?)?,
|
||||
hidden: boolean?, -- routable but not listed (e.g. search results)
|
||||
mount: (frame: Frame) -> PageHandle,
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
railWidth: number?,
|
||||
initialId: string?,
|
||||
onNavigate: ((id: string) -> ())?,
|
||||
onSearch: ((query: string) -> ())?,
|
||||
}
|
||||
|
||||
local ITEM_H = 24
|
||||
local SEARCH_H = 26
|
||||
|
||||
function Nav.mount(root: Frame, pages: { Page }, opts: Options?)
|
||||
local options: Options = opts or {}
|
||||
local railWidth = options.railWidth or 156
|
||||
|
||||
local byId: { [string]: Page } = {}
|
||||
for _, page in pages do
|
||||
byId[page.id] = page
|
||||
end
|
||||
|
||||
-- ── Layout: rail (left) + detail (right) ────────────────────────────────
|
||||
local rail = Theme.make("Frame", {
|
||||
Size = UDim2.new(0, railWidth, 1, 0),
|
||||
BackgroundColor3 = Theme.COLOR.RAIL,
|
||||
BorderSizePixel = 0,
|
||||
}) :: Frame
|
||||
rail.Parent = root
|
||||
|
||||
local detail = Theme.make("Frame", {
|
||||
Position = UDim2.fromOffset(railWidth, 0),
|
||||
Size = UDim2.new(1, -railWidth, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}) :: Frame
|
||||
detail.Parent = root
|
||||
|
||||
-- Search box pinned to the rail top.
|
||||
local searchBox = Theme.textBox({
|
||||
Size = UDim2.new(1, -12, 0, SEARCH_H),
|
||||
Position = UDim2.fromOffset(6, 6),
|
||||
Text = "",
|
||||
PlaceholderText = "Search content…",
|
||||
TextSize = 12,
|
||||
})
|
||||
searchBox.Parent = rail
|
||||
if options.onSearch then
|
||||
searchBox:GetPropertyChangedSignal("Text"):Connect(function()
|
||||
options.onSearch(searchBox.Text)
|
||||
end)
|
||||
end
|
||||
|
||||
local railScroll = Theme.make("ScrollingFrame", {
|
||||
Position = UDim2.fromOffset(0, SEARCH_H + 12),
|
||||
Size = UDim2.new(1, 0, 1, -(SEARCH_H + 12)),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 4,
|
||||
CanvasSize = UDim2.new(),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
Theme.pad(6, 6, 0, 10),
|
||||
}) :: ScrollingFrame
|
||||
railScroll.Parent = rail
|
||||
|
||||
-- ── Router state ─────────────────────────────────────────────────────────
|
||||
local frames: { [string]: Frame } = {}
|
||||
local handles: { [string]: PageHandle } = {}
|
||||
local dirty: { [string]: boolean } = {}
|
||||
local activeId: string? = nil
|
||||
local items: { [string]: { button: TextButton, label: TextLabel, bar: Frame, badge: TextLabel? } } = {}
|
||||
|
||||
local function paintItem(id: string)
|
||||
local item = items[id]
|
||||
if not item then
|
||||
return
|
||||
end
|
||||
local selected = id == activeId
|
||||
item.button.BackgroundColor3 = if selected then Theme.COLOR.PANEL else Theme.COLOR.RAIL
|
||||
item.button.BackgroundTransparency = if selected then 0 else 1
|
||||
item.label.TextColor3 = if selected then Theme.COLOR.TEXT else Theme.COLOR.DIM
|
||||
item.bar.Visible = selected
|
||||
end
|
||||
|
||||
local function refreshBadges()
|
||||
for id, item in items do
|
||||
local page = byId[id]
|
||||
if item.badge and page and page.badge then
|
||||
local count = page.badge()
|
||||
item.badge.Text = if count ~= nil then tostring(count) else ""
|
||||
item.badge.Visible = count ~= nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local api = {}
|
||||
|
||||
function api.selectPage(id: string)
|
||||
local page = byId[id]
|
||||
if not page then
|
||||
return
|
||||
end
|
||||
if not frames[id] then
|
||||
local frame = Theme.make("Frame", {
|
||||
Size = UDim2.fromScale(1, 1),
|
||||
BackgroundColor3 = Theme.COLOR.BG,
|
||||
BorderSizePixel = 0,
|
||||
Visible = false,
|
||||
}) :: Frame
|
||||
frame.Parent = detail
|
||||
frames[id] = frame
|
||||
handles[id] = page.mount(frame)
|
||||
dirty[id] = nil -- mount() renders fresh
|
||||
end
|
||||
if activeId ~= id then
|
||||
local previous = activeId and frames[activeId :: string]
|
||||
if previous then
|
||||
previous.Visible = false
|
||||
end
|
||||
local old = activeId
|
||||
activeId = id
|
||||
frames[id].Visible = true
|
||||
if old then
|
||||
paintItem(old)
|
||||
end
|
||||
paintItem(id)
|
||||
end
|
||||
if dirty[id] then
|
||||
dirty[id] = nil
|
||||
handles[id].refresh()
|
||||
end
|
||||
if options.onNavigate and not page.hidden then
|
||||
options.onNavigate(id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh the active page now; mark every other mounted page to refresh on next show.
|
||||
function api.refreshAll()
|
||||
for id in handles do
|
||||
if id == activeId then
|
||||
handles[id].refresh()
|
||||
else
|
||||
dirty[id] = true
|
||||
end
|
||||
end
|
||||
refreshBadges()
|
||||
end
|
||||
|
||||
function api.getActiveId(): string?
|
||||
return activeId
|
||||
end
|
||||
|
||||
-- ── Rail rendering (grouped, ordered as given) ───────────────────────────
|
||||
local order = 0
|
||||
local seenSections: { [string]: boolean } = {}
|
||||
for _, page in pages do
|
||||
if page.hidden then
|
||||
continue
|
||||
end
|
||||
if page.section ~= "" and not seenSections[page.section] then
|
||||
seenSections[page.section] = true
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = string.upper(page.section),
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 10,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
railScroll
|
||||
end
|
||||
order += 1
|
||||
|
||||
local button = Theme.make("TextButton", {
|
||||
Size = UDim2.new(1, 0, 0, ITEM_H),
|
||||
BackgroundColor3 = Theme.COLOR.RAIL,
|
||||
BackgroundTransparency = 1,
|
||||
AutoButtonColor = false,
|
||||
Text = "",
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = order,
|
||||
}, { Theme.corner(4) }) :: TextButton
|
||||
button.Parent = railScroll
|
||||
local label = Theme.make("TextLabel", {
|
||||
Size = UDim2.new(1, -34, 1, 0),
|
||||
Position = UDim2.fromOffset(10, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = page.label,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
TextSize = 12,
|
||||
Font = Theme.FONT,
|
||||
}) :: TextLabel
|
||||
label.Parent = button
|
||||
|
||||
local bar = Theme.make("Frame", {
|
||||
Size = UDim2.new(0, 2, 1, -6),
|
||||
Position = UDim2.fromOffset(0, 3),
|
||||
BackgroundColor3 = Theme.COLOR.ACCENT,
|
||||
BorderSizePixel = 0,
|
||||
Visible = false,
|
||||
}) :: Frame
|
||||
bar.Parent = button
|
||||
|
||||
local badgeLabel: TextLabel? = nil
|
||||
if page.badge then
|
||||
badgeLabel = Theme.badge("")
|
||||
local badge = badgeLabel :: TextLabel
|
||||
badge.AnchorPoint = Vector2.new(1, 0.5)
|
||||
badge.Position = UDim2.new(1, -4, 0.5, 0)
|
||||
badge.Parent = button
|
||||
end
|
||||
|
||||
items[page.id] = { button = button, label = label, bar = bar, badge = badgeLabel }
|
||||
button.MouseEnter:Connect(function()
|
||||
if page.id ~= activeId then
|
||||
button.BackgroundTransparency = 0
|
||||
button.BackgroundColor3 = Theme.COLOR.PANEL_HOVER
|
||||
end
|
||||
end)
|
||||
button.MouseLeave:Connect(function()
|
||||
paintItem(page.id)
|
||||
end)
|
||||
button.MouseButton1Click:Connect(function()
|
||||
api.selectPage(page.id)
|
||||
end)
|
||||
end
|
||||
|
||||
refreshBadges()
|
||||
api.selectPage(options.initialId or pages[1].id)
|
||||
|
||||
return api
|
||||
end
|
||||
|
||||
return Nav
|
||||
@@ -0,0 +1,124 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
OverviewPage — the Studio window's home page: is the engine synced, what content exists
|
||||
(click a row to jump to its editor), and where the docs live.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local OverviewPage = {}
|
||||
|
||||
-- deps = { StatAdmin, ContentAdmin, selectPage(id) }
|
||||
function OverviewPage.mount(container: Frame, deps: any): { refresh: () -> () }
|
||||
local header = Theme.header(container, "SurvivorCore Studio")
|
||||
local scroll = Theme.scroll(32, 0, 6)
|
||||
scroll.Parent = container
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- Engine status.
|
||||
local roster = deps.StatAdmin.readRoster()
|
||||
local status = Theme.panel()
|
||||
status.LayoutOrder = 1
|
||||
status.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = "Engine",
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
status
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = if roster.ok
|
||||
then `✓ Synced — {roster.source or "ReplicatedStorage.SurvivorCore"}. Stats, engine config and content editors are live.`
|
||||
else `✗ {roster.reason or "Engine not found in this place."}`,
|
||||
TextColor3 = if roster.ok then Theme.COLOR.DIM else Theme.COLOR.DANGER,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
status
|
||||
|
||||
-- Content counts (click-through).
|
||||
local content = Theme.panel()
|
||||
content.LayoutOrder = 2
|
||||
content.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = "Content",
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
content
|
||||
for i, catKey in deps.ContentAdmin.ORDER do
|
||||
local cat = deps.ContentAdmin.CATEGORIES[catKey]
|
||||
local count = #deps.ContentAdmin.listRoster(catKey)
|
||||
local row = Theme.make("TextButton", {
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
AutoButtonColor = false,
|
||||
Text = "",
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = i,
|
||||
}, { Theme.corner(4) }) :: TextButton
|
||||
row.Parent = content
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, -40, 1, 0),
|
||||
Position = UDim2.fromOffset(6, 0),
|
||||
Text = cat.title,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 12,
|
||||
}).Parent =
|
||||
row
|
||||
local badge = Theme.badge(tostring(count), Theme.COLOR.TEXT)
|
||||
badge.AnchorPoint = Vector2.new(1, 0.5)
|
||||
badge.Position = UDim2.new(1, -4, 0.5, 0)
|
||||
badge.Parent = row
|
||||
Theme.hover(row, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER)
|
||||
row.MouseButton1Click:Connect(function()
|
||||
deps.selectPage("content/" .. catKey)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Pointers.
|
||||
local help = Theme.panel()
|
||||
help.LayoutOrder = 3
|
||||
help.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = "How it works",
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
help
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = "Everything here is no-code and deltas-only: a field is written ONLY when it"
|
||||
.. " differs from the engine default, so unset fields keep following engine updates."
|
||||
.. " Stats apply live; Engine Config and content edits apply on the next Play."
|
||||
.. " Docs: docs/admin-plugin.md in the SurvivorCore repo.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
help
|
||||
end
|
||||
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
return OverviewPage
|
||||
@@ -0,0 +1,104 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
SearchPage — global content search results. A hidden (rail-less) page the plugin main routes
|
||||
to while the rail search box has text. Matches ids + `name` attributes across every category
|
||||
(ContentAdminUi.matchEntry) and renders full EDITABLE entry panels grouped by category — you
|
||||
fix the thing right where you found it.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local MAX_RESULTS = 50
|
||||
|
||||
local SearchPage = {}
|
||||
|
||||
-- deps = { ContentAdmin, ContentAdminUi, getQuery(), actions }
|
||||
function SearchPage.mount(container: Frame, deps: any): { refresh: () -> () }
|
||||
local header = Theme.header(container, "Search")
|
||||
local scroll = Theme.scroll(32, 0, 8)
|
||||
scroll.Parent = container
|
||||
|
||||
local function refresh()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
local query = tostring(deps.getQuery() or "")
|
||||
if string.match(query, "^%s*$") then
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 40),
|
||||
Text = "Type in the sidebar box to search content by id or name.",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
return
|
||||
end
|
||||
local lowered = string.lower(query)
|
||||
|
||||
local order = 0
|
||||
local shown = 0
|
||||
local total = 0
|
||||
for _, catKey in deps.ContentAdmin.ORDER do
|
||||
local cat = deps.ContentAdmin.CATEGORIES[catKey]
|
||||
local matches = {}
|
||||
for _, entry in deps.ContentAdmin.listRoster(catKey) do
|
||||
if deps.ContentAdminUi.matchEntry(deps.ContentAdmin, catKey, entry, lowered) then
|
||||
table.insert(matches, entry)
|
||||
end
|
||||
end
|
||||
total += #matches
|
||||
if #matches == 0 or shown >= MAX_RESULTS then
|
||||
continue -- past the cap, count the matches but don't render an empty header
|
||||
end
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = `{cat.title} — {#matches} match{if #matches == 1 then "" else "es"}`,
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 13,
|
||||
Font = Theme.FONT_BOLD,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
for _, entry in matches do
|
||||
if shown >= MAX_RESULTS then
|
||||
break
|
||||
end
|
||||
shown += 1
|
||||
order += 1
|
||||
local panel = deps.ContentAdminUi.buildRosterEntry(catKey, entry, deps.ContentAdmin, deps.actions)
|
||||
panel.LayoutOrder = order
|
||||
panel.Parent = scroll
|
||||
end
|
||||
end
|
||||
|
||||
if total == 0 then
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 40),
|
||||
Text = `No content matches '{query}'.`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
}).Parent =
|
||||
scroll
|
||||
elseif total > shown then
|
||||
order += 1
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = `Showing {shown} of {total} — refine your search.`,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
end
|
||||
end
|
||||
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
return SearchPage
|
||||
@@ -0,0 +1,117 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
SpawnHelpers — the "Add to World" branch logic, moved verbatim out of the plugin main. Drops a
|
||||
tagged, def-linked instance in front of the camera and selects it — the no-code bridge between
|
||||
an editor entry and the world:
|
||||
Resources → a Part tagged "Gatherable" with Resource = id.
|
||||
Mobs → a placeholder rig Model tagged "Mob" with MobType = id.
|
||||
Quests → a quest-giver post tagged "QuestGiver" with Quest = id.
|
||||
Weapons → a starter Tool (Handle + ToolType/WeaponKind) to model the equipped look on.
|
||||
]]
|
||||
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local Selection = game:GetService("Selection")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
|
||||
local SpawnHelpers = {}
|
||||
|
||||
local function spawnPosition(): Vector3
|
||||
local cam = Workspace.CurrentCamera
|
||||
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
|
||||
end
|
||||
|
||||
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
|
||||
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
|
||||
local function buildMobRig(id: string): Model
|
||||
local model = Instance.new("Model")
|
||||
model.Name = id
|
||||
local rootPart = Instance.new("Part")
|
||||
rootPart.Name = "HumanoidRootPart"
|
||||
rootPart.Size = Vector3.new(2, 3, 1)
|
||||
rootPart.Color = Color3.fromRGB(150, 60, 60)
|
||||
rootPart.Anchored = false
|
||||
rootPart.Parent = model
|
||||
local head = Instance.new("Part")
|
||||
head.Name = "Head"
|
||||
head.Shape = Enum.PartType.Ball
|
||||
head.Size = Vector3.new(1.4, 1.4, 1.4)
|
||||
head.Color = rootPart.Color
|
||||
head.CanCollide = false
|
||||
head.Massless = true
|
||||
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
|
||||
head.Parent = model
|
||||
local weld = Instance.new("WeldConstraint")
|
||||
weld.Part0 = rootPart
|
||||
weld.Part1 = head
|
||||
weld.Parent = rootPart
|
||||
local hum = Instance.new("Humanoid")
|
||||
hum.Parent = model
|
||||
model.PrimaryPart = rootPart
|
||||
return model
|
||||
end
|
||||
|
||||
function SpawnHelpers.spawn(ContentAdmin: any, catKey: string, id: string): Instance
|
||||
if catKey == "Mobs" then
|
||||
local model = buildMobRig(id)
|
||||
model:SetAttribute("MobType", id)
|
||||
model:PivotTo(CFrame.new(spawnPosition()))
|
||||
CollectionService:AddTag(model, "Mob")
|
||||
model.Parent = Workspace
|
||||
Selection:Set({ model })
|
||||
return model
|
||||
end
|
||||
if catKey == "Quests" then
|
||||
-- Drop a quest-giver post: tag + Quest attribute is exactly what the engine reads. The
|
||||
-- creator swaps in their own NPC/notice-board model later (tag it and set Quest).
|
||||
local post = Instance.new("Part")
|
||||
post.Name = id .. "_giver"
|
||||
post.Size = Vector3.new(1, 5, 1)
|
||||
post.Anchored = true
|
||||
post.Color = Color3.fromRGB(120, 170, 255)
|
||||
post.Material = Enum.Material.Wood
|
||||
post.Position = spawnPosition()
|
||||
post:SetAttribute("Quest", id)
|
||||
CollectionService:AddTag(post, "QuestGiver")
|
||||
post.Parent = Workspace
|
||||
Selection:Set({ post })
|
||||
return post
|
||||
end
|
||||
if catKey == "Weapons" then
|
||||
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
|
||||
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
|
||||
-- (named by this id) to make it the equipped look the hotbar clones.
|
||||
local node = ContentAdmin.getNode("Weapons", id)
|
||||
local tool = Instance.new("Tool")
|
||||
tool.Name = id
|
||||
tool.RequiresHandle = true
|
||||
tool.CanBeDropped = false
|
||||
local handle = Instance.new("Part")
|
||||
handle.Name = "Handle"
|
||||
handle.Size = Vector3.new(0.5, 3, 0.5)
|
||||
handle.Color = Color3.fromRGB(120, 90, 60)
|
||||
handle.Anchored = true
|
||||
handle.Position = spawnPosition()
|
||||
handle.Parent = tool
|
||||
for _, attr in { "toolType", "weaponKind" } do
|
||||
local v = node and node:GetAttribute(attr)
|
||||
if typeof(v) == "string" and v ~= "" then
|
||||
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
|
||||
end
|
||||
end
|
||||
tool.Parent = Workspace
|
||||
Selection:Set({ tool })
|
||||
return tool
|
||||
end
|
||||
local part = Instance.new("Part")
|
||||
part.Name = id
|
||||
part.Size = Vector3.new(4, 4, 4)
|
||||
part.Anchored = true
|
||||
part.Position = spawnPosition()
|
||||
part:SetAttribute("Resource", id)
|
||||
CollectionService:AddTag(part, "Gatherable")
|
||||
part.Parent = Workspace
|
||||
Selection:Set({ part })
|
||||
return part
|
||||
end
|
||||
|
||||
return SpawnHelpers
|
||||
+15
-25
@@ -6,34 +6,22 @@
|
||||
ChangeHistoryService recording, then refresh). Styling follows docs/design-language.md.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
|
||||
local StatAdminUi = {}
|
||||
|
||||
local COL_BG = Color3.fromRGB(18, 21, 28)
|
||||
local COL_PANEL = Color3.fromRGB(28, 32, 42)
|
||||
local COL_TEXT = Color3.fromRGB(235, 238, 245)
|
||||
local COL_DIM = Color3.fromRGB(150, 160, 180)
|
||||
local COL_ACCENT = Color3.fromRGB(120, 170, 255) -- marks an overridden field
|
||||
local COL_FIELD = Color3.fromRGB(38, 43, 56)
|
||||
local FONT = Enum.Font.GothamMedium
|
||||
local ROW_H = 26
|
||||
local COL_BG = Theme.COLOR.BG
|
||||
local COL_PANEL = Theme.COLOR.PANEL
|
||||
local COL_TEXT = Theme.COLOR.TEXT
|
||||
local COL_DIM = Theme.COLOR.DIM
|
||||
local COL_ACCENT = Theme.COLOR.ACCENT -- marks an overridden field
|
||||
local COL_FIELD = Theme.COLOR.FIELD
|
||||
local FONT = Theme.FONT
|
||||
local ROW_H = Theme.ROW_H
|
||||
local FOOTER_H = 36 -- the Preview / Clear control bar at the bottom
|
||||
|
||||
local function make(class: string, props: { [string]: any }, children: { Instance }?): Instance
|
||||
local inst = Instance.new(class)
|
||||
for key, value in props do
|
||||
(inst :: any)[key] = value
|
||||
end
|
||||
if children then
|
||||
for _, child in children do
|
||||
child.Parent = inst
|
||||
end
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
local function corner(radius: number): Instance
|
||||
return make("UICorner", { CornerRadius = UDim.new(0, radius) })
|
||||
end
|
||||
local make = Theme.make
|
||||
local corner = Theme.corner
|
||||
|
||||
-- A single field row: [reset] label .... control. `applyEdit/applyReset` take (stat, attr, …).
|
||||
local function buildRow(stat: string, spec: any, eff: any, applyEdit, applyReset): Instance
|
||||
@@ -276,7 +264,9 @@ function StatAdminUi.mount(
|
||||
BackgroundColor3 = COL_PANEL,
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = order,
|
||||
Size = UDim2.new(1, 0, 0, 22 + ROW_H * #StatAdmin.TUNABLE + 8),
|
||||
-- Content height = padding (4+4) + header (22) + rows (ROW_H each) + 2px list
|
||||
-- gaps between the header and every row — the +2 per row below covers the gaps.
|
||||
Size = UDim2.new(1, 0, 0, 22 + (ROW_H + 2) * #StatAdmin.TUNABLE + 10),
|
||||
AutomaticSize = Enum.AutomaticSize.None,
|
||||
}, {
|
||||
corner(8),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Theme — the plugin's shared visual system. One home for the style constants that used to be
|
||||
copy-pasted across every UI file, plus the small builders (instance factory, header, scroll,
|
||||
panel, styled controls) every page composes from. The palette is the dense/dark editor variant
|
||||
of docs/design-language.md.
|
||||
]]
|
||||
|
||||
local Theme = {}
|
||||
|
||||
Theme.COLOR = {
|
||||
BG = Color3.fromRGB(18, 21, 28),
|
||||
RAIL = Color3.fromRGB(14, 17, 23), -- sidebar, a step darker than the page
|
||||
PANEL = Color3.fromRGB(28, 32, 42),
|
||||
PANEL_HOVER = Color3.fromRGB(34, 39, 52),
|
||||
FIELD = Color3.fromRGB(38, 43, 56),
|
||||
TEXT = Color3.fromRGB(235, 238, 245),
|
||||
DIM = Color3.fromRGB(150, 160, 180),
|
||||
ACCENT = Color3.fromRGB(120, 170, 255),
|
||||
DANGER = Color3.fromRGB(210, 90, 90),
|
||||
STROKE = Color3.fromRGB(210, 220, 245),
|
||||
}
|
||||
|
||||
Theme.FONT = Enum.Font.GothamMedium
|
||||
Theme.FONT_BOLD = Enum.Font.GothamBold
|
||||
Theme.ROW_H = 26
|
||||
|
||||
-- Generic instance factory: props dict + optional children (reparented in order).
|
||||
function Theme.make(class: string, props: { [string]: any }, children: { Instance }?): Instance
|
||||
local inst = Instance.new(class)
|
||||
for key, value in props do
|
||||
(inst :: any)[key] = value
|
||||
end
|
||||
if children then
|
||||
for _, child in children do
|
||||
child.Parent = inst
|
||||
end
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
function Theme.corner(radius: number): Instance
|
||||
return Theme.make("UICorner", { CornerRadius = UDim.new(0, radius) })
|
||||
end
|
||||
|
||||
function Theme.stroke(transparency: number?): Instance
|
||||
return Theme.make("UIStroke", {
|
||||
Color = Theme.COLOR.STROKE,
|
||||
Transparency = transparency or 0.9,
|
||||
ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
|
||||
})
|
||||
end
|
||||
|
||||
function Theme.pad(left: number, right: number, top: number, bottom: number): Instance
|
||||
return Theme.make("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, left),
|
||||
PaddingRight = UDim.new(0, right),
|
||||
PaddingTop = UDim.new(0, top),
|
||||
PaddingBottom = UDim.new(0, bottom),
|
||||
})
|
||||
end
|
||||
|
||||
-- Styled primitives: sensible defaults, overridable via props.
|
||||
function Theme.label(props: { [string]: any }): TextLabel
|
||||
local merged: { [string]: any } = {
|
||||
BackgroundTransparency = 1,
|
||||
TextColor3 = Theme.COLOR.TEXT,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 13,
|
||||
Font = Theme.FONT,
|
||||
}
|
||||
for k, v in props do
|
||||
merged[k] = v
|
||||
end
|
||||
return Theme.make("TextLabel", merged) :: TextLabel
|
||||
end
|
||||
|
||||
function Theme.button(props: { [string]: any }): TextButton
|
||||
local merged: { [string]: any } = {
|
||||
BackgroundColor3 = Theme.COLOR.FIELD,
|
||||
AutoButtonColor = true,
|
||||
TextColor3 = Theme.COLOR.TEXT,
|
||||
TextSize = 12,
|
||||
Font = Theme.FONT,
|
||||
BorderSizePixel = 0,
|
||||
}
|
||||
for k, v in props do
|
||||
merged[k] = v
|
||||
end
|
||||
return Theme.make("TextButton", merged, { Theme.corner(4) }) :: TextButton
|
||||
end
|
||||
|
||||
function Theme.textBox(props: { [string]: any }): TextBox
|
||||
local merged: { [string]: any } = {
|
||||
BackgroundColor3 = Theme.COLOR.FIELD,
|
||||
TextColor3 = Theme.COLOR.TEXT,
|
||||
TextSize = 13,
|
||||
Font = Theme.FONT,
|
||||
ClearTextOnFocus = false,
|
||||
BorderSizePixel = 0,
|
||||
}
|
||||
for k, v in props do
|
||||
merged[k] = v
|
||||
end
|
||||
return Theme.make("TextBox", merged, {
|
||||
Theme.corner(4),
|
||||
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
|
||||
}) :: TextBox
|
||||
end
|
||||
|
||||
-- The standard 32px page header: bold title left, Refresh button right.
|
||||
function Theme.header(container: Instance, title: string): { frame: Frame, refreshBtn: TextButton }
|
||||
local frame = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 32),
|
||||
BackgroundTransparency = 1,
|
||||
}) :: Frame
|
||||
frame.Parent = container
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, -84, 1, 0),
|
||||
Position = UDim2.fromOffset(12, 0),
|
||||
Text = title,
|
||||
TextSize = 15,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
frame
|
||||
local refreshBtn = Theme.button({
|
||||
Size = UDim2.fromOffset(72, 22),
|
||||
Position = UDim2.new(1, -80, 0.5, -11),
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
Text = "Refresh",
|
||||
})
|
||||
refreshBtn.Parent = frame
|
||||
return { frame = frame, refreshBtn = refreshBtn }
|
||||
end
|
||||
|
||||
-- The standard page scroll: CanvasSize reset + AutomaticCanvasSize.Y + list layout. Keep this
|
||||
-- exact recipe — the explicit `CanvasSize = UDim2.new()` matters (the default canvas adds
|
||||
-- phantom scroll).
|
||||
function Theme.scroll(offsetTop: number, offsetBottom: number, listPadding: number?): ScrollingFrame
|
||||
return Theme.make("ScrollingFrame", {
|
||||
Size = UDim2.new(1, 0, 1, -(offsetTop + offsetBottom)),
|
||||
Position = UDim2.fromOffset(0, offsetTop),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 6,
|
||||
CanvasSize = UDim2.new(),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
Theme.make("UIListLayout", {
|
||||
Padding = UDim.new(0, listPadding or 4),
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
}),
|
||||
Theme.pad(10, 10, 6, 12),
|
||||
}) :: ScrollingFrame
|
||||
end
|
||||
|
||||
-- The standard content panel: PANEL background, rounded, subtle stroke, padded list layout.
|
||||
-- Grows with content by default (AutomaticSize.Y); override Size/AutomaticSize for fixed panels.
|
||||
function Theme.panel(): Frame
|
||||
return Theme.make("Frame", {
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
}, {
|
||||
Theme.corner(8),
|
||||
Theme.stroke(),
|
||||
Theme.pad(8, 8, 6, 8),
|
||||
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
}) :: Frame
|
||||
end
|
||||
|
||||
-- A small count/status pill (rail badges, entry badges).
|
||||
function Theme.badge(text: string, textColor: Color3?): TextLabel
|
||||
return Theme.make("TextLabel", {
|
||||
AutomaticSize = Enum.AutomaticSize.X,
|
||||
Size = UDim2.fromOffset(0, 14),
|
||||
BackgroundColor3 = Theme.COLOR.FIELD,
|
||||
BorderSizePixel = 0,
|
||||
Text = text,
|
||||
TextColor3 = textColor or Theme.COLOR.DIM,
|
||||
TextSize = 10,
|
||||
Font = Theme.FONT,
|
||||
}, {
|
||||
Theme.corner(7),
|
||||
Theme.make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }),
|
||||
}) :: TextLabel
|
||||
end
|
||||
|
||||
-- MouseEnter/Leave background tint for custom (non-AutoButtonColor) elements.
|
||||
function Theme.hover(gui: GuiObject, base: Color3, hoverColor: Color3)
|
||||
gui.MouseEnter:Connect(function()
|
||||
gui.BackgroundColor3 = hoverColor
|
||||
end)
|
||||
gui.MouseLeave:Connect(function()
|
||||
gui.BackgroundColor3 = base
|
||||
end)
|
||||
end
|
||||
|
||||
return Theme
|
||||
+235
-220
@@ -1,107 +1,79 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
SurvivorCore — admin plugin (main).
|
||||
SurvivorCore Studio — admin plugin (main).
|
||||
|
||||
ONE dock widget, a tab bar across the top, one feature per tab:
|
||||
• Stats — tune the survival stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
|
||||
• Content — create/edit/delete Items + Gatherable resources (no-code) as SurvivorCoreContent
|
||||
instances the engine loads at start().
|
||||
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step) and then
|
||||
both editors refresh. All real logic lives in the requirable modules (StatAdmin / ContentAdmin);
|
||||
the forms in StatAdminUi / ContentAdminUi. This is the Builder / Admin plugin (issue #11), and
|
||||
new sections (Movement / Consequences, #21) slot in as more tabs.
|
||||
ONE floating (dockable) window with a sidebar rail and a detail pane, one page per editor:
|
||||
• Overview — engine status + content counts (click-through) + how-it-works.
|
||||
• Survival Stats — tune stats (deltas-only, locked) on SurvivalStatsConfig + HUD preview.
|
||||
• Engine Config — every engine Config section (Movement/Combat/Mobs/…/UI theme) as
|
||||
deltas-only overrides on SurvivorCoreEngineConfig (issue #21).
|
||||
• Content — create/edit/delete Items/Weapons/Arrows/Resources/Mobs/Quests/Achievements
|
||||
as SurvivorCoreContent instances, plus OVERRIDES that tune code-registered
|
||||
defs (issue #40). A rail search box finds any entry by id/name.
|
||||
|
||||
Every mutation is wrapped in a single ChangeHistoryService recording (one undo step), then the
|
||||
active page refreshes (others refresh on next show). All real logic lives in the requirable
|
||||
modules (StatAdmin / ConfigAdmin / ContentAdmin); the forms in the *Ui modules; the shared look
|
||||
in Theme. This is the Builder / Admin plugin (issue #11).
|
||||
]]
|
||||
|
||||
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local Selection = game:GetService("Selection")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
|
||||
local Theme = require(script.Theme)
|
||||
local Nav = require(script.Nav)
|
||||
local SpawnHelpers = require(script.SpawnHelpers)
|
||||
local OverviewPage = require(script.OverviewPage)
|
||||
local SearchPage = require(script.SearchPage)
|
||||
local StatAdmin = require(script.StatAdmin)
|
||||
local StatAdminUi = require(script.StatAdminUi)
|
||||
local HudPreview = require(script.HudPreview)
|
||||
local ContentAdmin = require(script.ContentAdmin)
|
||||
local ContentAdminUi = require(script.ContentAdminUi)
|
||||
local ConfigAdmin = require(script.ConfigAdmin)
|
||||
local ConfigAdminUi = require(script.ConfigAdminUi)
|
||||
|
||||
local COL_BG = Color3.fromRGB(18, 21, 28)
|
||||
local COL_TAB = Color3.fromRGB(28, 32, 42)
|
||||
local COL_TAB_ON = Color3.fromRGB(52, 58, 74)
|
||||
local COL_TEXT = Color3.fromRGB(235, 238, 245)
|
||||
local TAB_H = 30
|
||||
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
||||
|
||||
local toolbar = plugin:CreateToolbar("SurvivorCore")
|
||||
local button = toolbar:CreateButton("Admin Panel", "Tune stats + create items/weapons/gatherables/mobs (no-code)", "")
|
||||
local button = toolbar:CreateButton("Studio", "Tune the engine + create items/weapons/gatherables/mobs (no-code)", "")
|
||||
button.ClickableWhenViewportHidden = true
|
||||
|
||||
-- CreateDockWidgetPluginGui is flagged deprecated by the tooling, but it is still the only API for
|
||||
-- a dockable plugin widget (there is no replacement) — so allow it here.
|
||||
-- a dockable plugin widget (there is no replacement) — so allow it here. NOTE: the widget ID is
|
||||
-- "SurvivorCoreStudio" (was "SurvivorCoreAdmin") — Studio persists dock state PER ID and ignores
|
||||
-- new initial values for known IDs, so the rename is what makes the floating default reach
|
||||
-- existing users (their old dock position resets once).
|
||||
-- selene: allow(deprecated)
|
||||
local widget = plugin:CreateDockWidgetPluginGui(
|
||||
"SurvivorCoreAdmin",
|
||||
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 340, 560, 320, 400)
|
||||
"SurvivorCoreStudio",
|
||||
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, false, 660, 580, 480, 380)
|
||||
)
|
||||
widget.Title = "SurvivorCore Admin"
|
||||
widget.Name = "SurvivorCoreAdmin"
|
||||
widget.Title = "SurvivorCore Studio"
|
||||
widget.Name = "SurvivorCoreStudio"
|
||||
|
||||
local root = Instance.new("Frame")
|
||||
root.Size = UDim2.fromScale(1, 1)
|
||||
root.BackgroundColor3 = COL_BG
|
||||
root.BackgroundColor3 = Theme.COLOR.BG
|
||||
root.BorderSizePixel = 0
|
||||
root.Parent = widget
|
||||
|
||||
-- Tab bar + content region.
|
||||
local tabBar = Instance.new("Frame")
|
||||
tabBar.Size = UDim2.new(1, 0, 0, TAB_H)
|
||||
tabBar.BackgroundColor3 = COL_BG
|
||||
tabBar.BorderSizePixel = 0
|
||||
tabBar.Parent = root
|
||||
local tabLayout = Instance.new("UIListLayout")
|
||||
tabLayout.FillDirection = Enum.FillDirection.Horizontal
|
||||
tabLayout.Padding = UDim.new(0, 4)
|
||||
tabLayout.Parent = tabBar
|
||||
local tabPad = Instance.new("UIPadding")
|
||||
tabPad.PaddingLeft = UDim.new(0, 8)
|
||||
tabPad.PaddingTop = UDim.new(0, 4)
|
||||
tabPad.Parent = tabBar
|
||||
local nav -- assigned after the pages are assembled; closures below capture it
|
||||
|
||||
local region = Instance.new("Frame")
|
||||
region.Position = UDim2.fromOffset(0, TAB_H)
|
||||
region.Size = UDim2.new(1, 0, 1, -TAB_H)
|
||||
region.BackgroundTransparency = 1
|
||||
region.BorderSizePixel = 0
|
||||
region.Parent = root
|
||||
|
||||
local statFrame = Instance.new("Frame")
|
||||
statFrame.Size = UDim2.fromScale(1, 1)
|
||||
statFrame.BackgroundTransparency = 1
|
||||
statFrame.Parent = region
|
||||
|
||||
local contentFrame = Instance.new("Frame")
|
||||
contentFrame.Size = UDim2.fromScale(1, 1)
|
||||
contentFrame.BackgroundTransparency = 1
|
||||
contentFrame.Visible = false
|
||||
contentFrame.Parent = region
|
||||
|
||||
local statUi: { refresh: () -> () }? = nil
|
||||
local contentUi: { refresh: () -> () }? = nil
|
||||
|
||||
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh both editors.
|
||||
-- Wrap a mutation in one ChangeHistory recording (one undo step), then refresh the UI.
|
||||
local function record(name: string, mutate: () -> any): any
|
||||
local recording = ChangeHistoryService:TryBeginRecording(name)
|
||||
local result = mutate()
|
||||
if recording then
|
||||
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
||||
end
|
||||
if statUi then
|
||||
statUi.refresh()
|
||||
end
|
||||
if contentUi then
|
||||
contentUi.refresh()
|
||||
if nav then
|
||||
nav.refreshAll()
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- Stats callbacks.
|
||||
-- ── Stats callbacks ───────────────────────────────────────────────────────────
|
||||
local function applyEdit(statName: string, attr: string, raw: any, default: any): any
|
||||
return record(`Stats: {statName}.{attr}`, function()
|
||||
return StatAdmin.setOverride(statName, attr, raw, default)
|
||||
@@ -123,178 +95,221 @@ local function applyClear(): any
|
||||
end)
|
||||
end
|
||||
|
||||
-- Content callbacks.
|
||||
local function applySet(catKey: string, id: string, field: any, raw: any): any
|
||||
return record(`Content: {catKey} {id}.{field.attr}`, function()
|
||||
return ContentAdmin.set(catKey, id, field, raw)
|
||||
-- ── Engine Config callbacks ───────────────────────────────────────────────────
|
||||
local function applyConfigEdit(sectionId: string, groupName: string?, field: any, raw: any, default: any): any
|
||||
return record(`Config: {sectionId}.{field.attr}`, function()
|
||||
return ConfigAdmin.setOverride(sectionId, groupName, field, raw, default)
|
||||
end)
|
||||
end
|
||||
local function applyCreate(catKey: string, rawId: any): any
|
||||
return record(`Content: create {catKey}`, function()
|
||||
return ContentAdmin.create(catKey, rawId)
|
||||
local function applyConfigReset(sectionId: string, groupName: string?, attr: string): any
|
||||
return record(`Config: reset {sectionId}.{attr}`, function()
|
||||
return ConfigAdmin.resetOverride(sectionId, groupName, attr)
|
||||
end)
|
||||
end
|
||||
local function applyDelete(catKey: string, id: string): any
|
||||
return record(`Content: delete {catKey} {id}`, function()
|
||||
ContentAdmin.delete(catKey, id)
|
||||
local function applyConfigResetSection(section: any): any
|
||||
return record(`Config: reset section {section.id}`, function()
|
||||
return ConfigAdmin.resetSection(section)
|
||||
end)
|
||||
end
|
||||
|
||||
local function spawnPosition(): Vector3
|
||||
local cam = Workspace.CurrentCamera
|
||||
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
|
||||
end
|
||||
|
||||
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
|
||||
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
|
||||
local function buildMobRig(id: string): Model
|
||||
local model = Instance.new("Model")
|
||||
model.Name = id
|
||||
local rootPart = Instance.new("Part")
|
||||
rootPart.Name = "HumanoidRootPart"
|
||||
rootPart.Size = Vector3.new(2, 3, 1)
|
||||
rootPart.Color = Color3.fromRGB(150, 60, 60)
|
||||
rootPart.Anchored = false
|
||||
rootPart.Parent = model
|
||||
local head = Instance.new("Part")
|
||||
head.Name = "Head"
|
||||
head.Shape = Enum.PartType.Ball
|
||||
head.Size = Vector3.new(1.4, 1.4, 1.4)
|
||||
head.Color = rootPart.Color
|
||||
head.CanCollide = false
|
||||
head.Massless = true
|
||||
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
|
||||
head.Parent = model
|
||||
local weld = Instance.new("WeldConstraint")
|
||||
weld.Part0 = rootPart
|
||||
weld.Part1 = head
|
||||
weld.Parent = rootPart
|
||||
local hum = Instance.new("Humanoid")
|
||||
hum.Parent = model
|
||||
model.PrimaryPart = rootPart
|
||||
return model
|
||||
end
|
||||
|
||||
-- "Add to World": drop a tagged, def-linked instance in front of the camera, then select it. The
|
||||
-- no-code way to place one — tag + def-id attribute is exactly what the engine reads at play.
|
||||
-- Resources → a Part tagged "Gatherable" with Resource = id.
|
||||
-- Mobs → a placeholder rig Model tagged "Mob" with MobType = id.
|
||||
local function applySpawn(catKey: string, id: string): any
|
||||
return record(`Content: add {catKey} '{id}' to world`, function()
|
||||
if catKey == "Mobs" then
|
||||
local model = buildMobRig(id)
|
||||
model:SetAttribute("MobType", id)
|
||||
model:PivotTo(CFrame.new(spawnPosition()))
|
||||
CollectionService:AddTag(model, "Mob")
|
||||
model.Parent = Workspace
|
||||
Selection:Set({ model })
|
||||
return model
|
||||
end
|
||||
if catKey == "Quests" then
|
||||
-- Drop a quest-giver post: tag + Quest attribute is exactly what the engine reads. The
|
||||
-- creator swaps in their own NPC/notice-board model later (tag it and set Quest).
|
||||
local post = Instance.new("Part")
|
||||
post.Name = id .. "_giver"
|
||||
post.Size = Vector3.new(1, 5, 1)
|
||||
post.Anchored = true
|
||||
post.Color = Color3.fromRGB(120, 170, 255)
|
||||
post.Material = Enum.Material.Wood
|
||||
post.Position = spawnPosition()
|
||||
post:SetAttribute("Quest", id)
|
||||
CollectionService:AddTag(post, "QuestGiver")
|
||||
post.Parent = Workspace
|
||||
Selection:Set({ post })
|
||||
return post
|
||||
end
|
||||
if catKey == "Weapons" then
|
||||
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
|
||||
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
|
||||
-- (named by this id) to make it the equipped look the hotbar clones.
|
||||
local node = ContentAdmin.getNode("Weapons", id)
|
||||
local tool = Instance.new("Tool")
|
||||
tool.Name = id
|
||||
tool.RequiresHandle = true
|
||||
tool.CanBeDropped = false
|
||||
local handle = Instance.new("Part")
|
||||
handle.Name = "Handle"
|
||||
handle.Size = Vector3.new(0.5, 3, 0.5)
|
||||
handle.Color = Color3.fromRGB(120, 90, 60)
|
||||
handle.Anchored = true
|
||||
handle.Position = spawnPosition()
|
||||
handle.Parent = tool
|
||||
for _, attr in { "toolType", "weaponKind" } do
|
||||
local v = node and node:GetAttribute(attr)
|
||||
if typeof(v) == "string" and v ~= "" then
|
||||
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
|
||||
end
|
||||
end
|
||||
tool.Parent = Workspace
|
||||
Selection:Set({ tool })
|
||||
return tool
|
||||
end
|
||||
local part = Instance.new("Part")
|
||||
part.Name = id
|
||||
part.Size = Vector3.new(4, 4, 4)
|
||||
part.Anchored = true
|
||||
part.Position = spawnPosition()
|
||||
part:SetAttribute("Resource", id)
|
||||
CollectionService:AddTag(part, "Gatherable")
|
||||
part.Parent = Workspace
|
||||
Selection:Set({ part })
|
||||
return part
|
||||
end)
|
||||
end
|
||||
|
||||
statUi = StatAdminUi.mount(statFrame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
|
||||
contentUi = ContentAdminUi.mount(contentFrame, ContentAdmin, applySet, applyCreate, applyDelete, applySpawn)
|
||||
|
||||
-- Tabs.
|
||||
local TABS = {
|
||||
{ id = "stats", label = "Stats", frame = statFrame },
|
||||
{ id = "content", label = "Content", frame = contentFrame },
|
||||
-- ── Content callbacks (authored + overrides), bundled for the pages ──────────
|
||||
local actions = {
|
||||
set = function(catKey: string, id: string, field: any, raw: any): any
|
||||
return record(`Content: {catKey} {id}.{field.attr}`, function()
|
||||
return ContentAdmin.set(catKey, id, field, raw)
|
||||
end)
|
||||
end,
|
||||
create = function(catKey: string, rawId: any): any
|
||||
return record(`Content: create {catKey}`, function()
|
||||
local ok, message = ContentAdmin.create(catKey, rawId)
|
||||
return { ok = ok, message = message }
|
||||
end)
|
||||
end,
|
||||
delete = function(catKey: string, id: string): any
|
||||
return record(`Content: delete {catKey} {id}`, function()
|
||||
ContentAdmin.delete(catKey, id)
|
||||
end)
|
||||
end,
|
||||
spawn = function(catKey: string, id: string): any
|
||||
return record(`Content: add {catKey} '{id}' to world`, function()
|
||||
return SpawnHelpers.spawn(ContentAdmin, catKey, id)
|
||||
end)
|
||||
end,
|
||||
createOverride = function(catKey: string, rawId: any): any
|
||||
return record(`Content: override {catKey}`, function()
|
||||
local ok, message = ContentAdmin.createOverride(catKey, rawId)
|
||||
return { ok = ok, message = message }
|
||||
end)
|
||||
end,
|
||||
setOverride = function(catKey: string, id: string, field: any, raw: any): any
|
||||
return record(`Content: override {catKey} {id}.{field.attr}`, function()
|
||||
return ContentAdmin.setOverrideField(catKey, id, field, raw)
|
||||
end)
|
||||
end,
|
||||
deleteOverride = function(catKey: string, id: string): any
|
||||
return record(`Content: remove override {catKey} {id}`, function()
|
||||
ContentAdmin.deleteOverride(catKey, id)
|
||||
end)
|
||||
end,
|
||||
}
|
||||
local tabButtons: { [string]: TextButton } = {}
|
||||
|
||||
local function showTab(id: string)
|
||||
for _, t in TABS do
|
||||
t.frame.Visible = t.id == id
|
||||
local b = tabButtons[t.id]
|
||||
if b then
|
||||
b.BackgroundColor3 = if t.id == id then COL_TAB_ON else COL_TAB
|
||||
-- ── Search state (rail box → hidden results page) ─────────────────────────────
|
||||
local currentQuery = ""
|
||||
local searchGeneration = 0
|
||||
local lastNonSearchId = "overview"
|
||||
|
||||
-- ── NAV assembly (data-driven; new editors slot in as more pages) ─────────────
|
||||
-- The whole window is built by mountUi() so it can REBUILD: opening an engine-less place yields
|
||||
-- a single Engine Config explainer page whose Refresh remounts everything once the engine syncs.
|
||||
local restoring = false
|
||||
|
||||
local function mountUi()
|
||||
for _, child in root:GetChildren() do
|
||||
child:Destroy()
|
||||
end
|
||||
|
||||
local pages = {}
|
||||
|
||||
table.insert(pages, {
|
||||
id = "overview",
|
||||
label = "Overview",
|
||||
section = "",
|
||||
mount = function(frame)
|
||||
return OverviewPage.mount(frame, {
|
||||
StatAdmin = StatAdmin,
|
||||
ContentAdmin = ContentAdmin,
|
||||
selectPage = function(id: string)
|
||||
nav.selectPage(id)
|
||||
end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(pages, {
|
||||
id = "stats",
|
||||
label = "Survival Stats",
|
||||
section = "Stats",
|
||||
mount = function(frame)
|
||||
return StatAdminUi.mount(frame, StatAdmin, applyEdit, applyReset, applyPreview, applyClear)
|
||||
end,
|
||||
})
|
||||
|
||||
local configPages = ConfigAdminUi.pages(
|
||||
ConfigAdmin,
|
||||
applyConfigEdit,
|
||||
applyConfigReset,
|
||||
applyConfigResetSection,
|
||||
function() -- onEngineFound: remount with the full section list
|
||||
task.defer(mountUi)
|
||||
end
|
||||
)
|
||||
for _, page in configPages do
|
||||
table.insert(pages, page)
|
||||
end
|
||||
|
||||
for _, catKey in ContentAdmin.ORDER do
|
||||
local cat = ContentAdmin.CATEGORIES[catKey]
|
||||
table.insert(pages, {
|
||||
id = "content/" .. catKey,
|
||||
label = cat.title,
|
||||
section = "Content",
|
||||
badge = function()
|
||||
return #ContentAdmin.listRoster(catKey)
|
||||
end,
|
||||
mount = function(frame)
|
||||
return ContentAdminUi.mountCategory(frame, catKey, ContentAdmin, actions)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
table.insert(pages, {
|
||||
id = "search",
|
||||
label = "Search",
|
||||
section = "",
|
||||
hidden = true,
|
||||
mount = function(frame)
|
||||
return SearchPage.mount(frame, {
|
||||
ContentAdmin = ContentAdmin,
|
||||
ContentAdminUi = ContentAdminUi,
|
||||
actions = actions,
|
||||
getQuery = function()
|
||||
return currentQuery
|
||||
end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
-- Restore the last-open page (validated against the live page list — ids may change across
|
||||
-- plugin versions or when the engine's schema grows/shrinks).
|
||||
local restoredId = "overview"
|
||||
local saved = plugin:GetSetting(SETTING_LAST_PAGE)
|
||||
if typeof(saved) == "string" then
|
||||
for _, page in pages do
|
||||
if page.id == saved and not page.hidden then
|
||||
restoredId = saved
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The mount-time selectPage must NOT write the setting: an engine-less session falls back to
|
||||
-- "overview", and persisting that would clobber a saved config page the user will want back.
|
||||
restoring = true
|
||||
nav = Nav.mount(root, pages, {
|
||||
initialId = restoredId,
|
||||
onNavigate = function(id: string)
|
||||
lastNonSearchId = id
|
||||
searchGeneration += 1 -- explicit navigation cancels any pending search-page jump
|
||||
if restoring then
|
||||
return
|
||||
end
|
||||
if plugin:GetSetting(SETTING_LAST_PAGE) ~= id then
|
||||
plugin:SetSetting(SETTING_LAST_PAGE, id)
|
||||
end
|
||||
end,
|
||||
onSearch = function(text: string)
|
||||
currentQuery = text
|
||||
searchGeneration += 1
|
||||
local generation = searchGeneration
|
||||
task.delay(0.25, function()
|
||||
if generation ~= searchGeneration or not widget.Enabled then
|
||||
return
|
||||
end
|
||||
if string.match(currentQuery, "^%s*$") then
|
||||
if nav.getActiveId() == "search" then
|
||||
nav.selectPage(lastNonSearchId)
|
||||
end
|
||||
else
|
||||
nav.selectPage("search")
|
||||
nav.refreshAll() -- re-render the results with the new query
|
||||
end
|
||||
end)
|
||||
end,
|
||||
})
|
||||
restoring = false
|
||||
end
|
||||
|
||||
for _, t in TABS do
|
||||
local b = Instance.new("TextButton")
|
||||
b.Size = UDim2.fromOffset(92, TAB_H - 6)
|
||||
b.BackgroundColor3 = COL_TAB
|
||||
b.AutoButtonColor = true
|
||||
b.Text = t.label
|
||||
b.TextColor3 = COL_TEXT
|
||||
b.TextSize = 13
|
||||
b.Font = Enum.Font.GothamMedium
|
||||
b.BorderSizePixel = 0
|
||||
b.Parent = tabBar
|
||||
local c = Instance.new("UICorner")
|
||||
c.CornerRadius = UDim.new(0, 4)
|
||||
c.Parent = b
|
||||
tabButtons[t.id] = b
|
||||
b.MouseButton1Click:Connect(function()
|
||||
showTab(t.id)
|
||||
end)
|
||||
mountUi()
|
||||
|
||||
-- An undo/redo can revert any edit the pages show — refresh so they never go stale. Skip while
|
||||
-- the user is mid-edit in one of OUR text boxes (the rebuild would destroy the focused box and
|
||||
-- eat the typed text); the per-page Refresh button covers that rare overlap.
|
||||
local function onHistoryChanged()
|
||||
local focused = UserInputService:GetFocusedTextBox()
|
||||
if focused and focused:IsDescendantOf(widget) then
|
||||
return
|
||||
end
|
||||
if nav then
|
||||
nav.refreshAll()
|
||||
end
|
||||
end
|
||||
showTab("stats")
|
||||
ChangeHistoryService.OnUndo:Connect(onHistoryChanged)
|
||||
ChangeHistoryService.OnRedo:Connect(onHistoryChanged)
|
||||
|
||||
button.Click:Connect(function()
|
||||
widget.Enabled = not widget.Enabled
|
||||
if widget.Enabled then
|
||||
if statUi then
|
||||
statUi.refresh()
|
||||
end
|
||||
if contentUi then
|
||||
contentUi.refresh()
|
||||
end
|
||||
nav.refreshAll()
|
||||
end
|
||||
end)
|
||||
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"SurvivorCoreStatAdmin","className":"Script","filePaths":["plugin/init.server.luau","plugin.project.json"],"children":[{"name":"ConfigAdmin","className":"ModuleScript","filePaths":["plugin/ConfigAdmin.luau"]},{"name":"ConfigAdminUi","className":"ModuleScript","filePaths":["plugin/ConfigAdminUi.luau"]},{"name":"ContentAdmin","className":"ModuleScript","filePaths":["plugin/ContentAdmin.luau"]},{"name":"ContentAdminUi","className":"ModuleScript","filePaths":["plugin/ContentAdminUi.luau"]},{"name":"HudPreview","className":"ModuleScript","filePaths":["plugin/HudPreview.luau"]},{"name":"Nav","className":"ModuleScript","filePaths":["plugin/Nav.luau"]},{"name":"OverviewPage","className":"ModuleScript","filePaths":["plugin/OverviewPage.luau"]},{"name":"SearchPage","className":"ModuleScript","filePaths":["plugin/SearchPage.luau"]},{"name":"SpawnHelpers","className":"ModuleScript","filePaths":["plugin/SpawnHelpers.luau"]},{"name":"StatAdmin","className":"ModuleScript","filePaths":["plugin/StatAdmin.luau"]},{"name":"StatAdminUi","className":"ModuleScript","filePaths":["plugin/StatAdminUi.luau"]},{"name":"Theme","className":"ModuleScript","filePaths":["plugin/Theme.luau"]}]}
|
||||
@@ -69,6 +69,32 @@ function Registry.new(name: string, options: Options?)
|
||||
end
|
||||
end
|
||||
|
||||
-- Owner override path (issue #40): field-merge each child Configuration's attributes onto the
|
||||
-- ALREADY-registered def with the same key (code- or folder-registered — the def table is
|
||||
-- shared by reference, so live .get()/.getAll() consumers see the merged fields). Deltas-only:
|
||||
-- an override child carries ONLY the attributes it changes. Unknown keys WARN (louder than
|
||||
-- loadFromFolder's silent skip — an override that matches nothing is a typo, and this is the
|
||||
-- only place it can be surfaced). The key field itself can't be overridden. Boot-time only:
|
||||
-- callers (SurvivorCore.start) run this before systems cache or replicate defs.
|
||||
function self.applyOverrides(folder: Instance?)
|
||||
if folder == nil then
|
||||
return
|
||||
end
|
||||
for _, child in folder:GetChildren() do
|
||||
local key = child.Name
|
||||
local def = byId[key]
|
||||
if def == nil then
|
||||
warn(`[{name}] override '{tostring(key)}' matches no registered def — skipped (typo?)`)
|
||||
continue
|
||||
end
|
||||
for attrName, value in child:GetAttributes() do
|
||||
if attrName ~= keyField then
|
||||
def[attrName] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function self.get(key: any): any
|
||||
return byId[key]
|
||||
end
|
||||
|
||||
@@ -63,6 +63,11 @@ require(script.shared.AchievementsConfig)
|
||||
-- before start().
|
||||
require(script.shared.LootBagsConfig)
|
||||
|
||||
-- The no-code layer over ALL of the sections above: the persisted SurvivorCoreEngineConfig
|
||||
-- instance (written by the admin plugin, deltas-only). apply() runs as the first step of
|
||||
-- start()/startClient(), AFTER game-code Config.override calls — the instance wins.
|
||||
local EngineConfig = require(script.shared.EngineConfig)
|
||||
|
||||
local SurvivorCore = {}
|
||||
|
||||
SurvivorCore.VERSION = "0.7.4"
|
||||
@@ -105,6 +110,11 @@ function SurvivorCore.start(_options: { [string]: any }?)
|
||||
assert(not started, "SurvivorCore.start() called twice")
|
||||
started = true
|
||||
|
||||
-- No-code engine tuning: layer the persisted SurvivorCoreEngineConfig instance's deltas onto
|
||||
-- every Config section BEFORE anything boots, so systems that cache config at start (and the
|
||||
-- ones that read live) all see the same resolved values. Edits apply on the next server start.
|
||||
EngineConfig.apply()
|
||||
|
||||
-- No-code content: register item/resource defs authored as instances (the admin plugin writes
|
||||
-- these under ReplicatedStorage.SurvivorCoreContent), complementing code register(). Done first,
|
||||
-- so Gatherable nodes can resolve their Resource def and systems see the full roster. Recipes
|
||||
@@ -122,6 +132,45 @@ function SurvivorCore.start(_options: { [string]: any }?)
|
||||
-- achievement defs are flat by design, so they load verbatim.
|
||||
Registries.Quests.loadFromFolder(content:FindFirstChild("Quests"))
|
||||
Registries.Achievements.loadFromFolder(content:FindFirstChild("Achievements"))
|
||||
|
||||
-- Owner overrides (issue #40): SurvivorCoreContent/Overrides mirrors the folders above and
|
||||
-- field-merges deltas onto ALREADY-registered defs — including code-registered ones, which
|
||||
-- the folders themselves can't touch (loadFromFolder skips existing keys). Applied here,
|
||||
-- before Components.scan and system starts, so bind-time stamps and the replicated data
|
||||
-- caches (ItemData/RecipeData/QuestData/AchievementData) all serialize the merged defs.
|
||||
local overrides = content:FindFirstChild("Overrides")
|
||||
if overrides then
|
||||
Registries.Items.applyOverrides(overrides:FindFirstChild("Items"))
|
||||
Registries.Items.applyOverrides(overrides:FindFirstChild("Weapons"))
|
||||
Registries.Items.applyOverrides(overrides:FindFirstChild("Arrows"))
|
||||
Registries.Resources.applyOverrides(overrides:FindFirstChild("Resources"))
|
||||
Registries.Mobs.applyOverrides(overrides:FindFirstChild("Mobs"))
|
||||
Registries.Quests.applyOverrides(overrides:FindFirstChild("Quests"))
|
||||
Registries.Achievements.applyOverrides(overrides:FindFirstChild("Achievements"))
|
||||
|
||||
-- Quest-override caveat, made LOUD: QuestData.normalize prefers a def's nested
|
||||
-- objectives/rewards tables, so flat objective*/reward* override fields silently do
|
||||
-- nothing on a code-registered quest that uses the canonical nested shape. The id
|
||||
-- matched, so applyOverrides' typo warning didn't fire — surface it here instead.
|
||||
local questOverrides = overrides:FindFirstChild("Quests")
|
||||
if questOverrides then
|
||||
for _, child in questOverrides:GetChildren() do
|
||||
local def = Registries.Quests.get(child.Name)
|
||||
if def and typeof(def.objectives) == "table" then
|
||||
for attr in child:GetAttributes() do
|
||||
if string.match(attr, "^objective") or string.match(attr, "^reward") then
|
||||
warn(
|
||||
`[SurvivorCore] quest override '{child.Name}': objective*/reward* fields are`
|
||||
.. " ignored for code quests with nested objectives — only name/description/"
|
||||
.. "autoStart/requires/turnIn merge"
|
||||
)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Load built-in components so their tags are recognised.
|
||||
@@ -242,6 +291,10 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
|
||||
end
|
||||
clientStarted = true
|
||||
|
||||
-- Same no-code tuning layer as the server (idempotent per VM; the client cares about the UI
|
||||
-- section — theme, keybinds, layout — which the client-side builders read at start).
|
||||
EngineConfig.apply()
|
||||
|
||||
require(script.client.Hud).start(_options)
|
||||
|
||||
-- Sprint input + low-stat vignette/breathing/heartbeat feedback.
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
EngineConfig — the no-code layer over EVERY engine Config section (issue #21). SHARED.
|
||||
|
||||
Owners tune the engine without code via ONE persisted instance:
|
||||
|
||||
ReplicatedStorage.SurvivorCoreEngineConfig (Configuration)
|
||||
├─ Movement (Configuration)
|
||||
│ └─ Energy / Movement / Health / Audio / Assets (Configurations; attributes = fields)
|
||||
├─ Combat (Configuration; root attributes) └─ Bow (Configuration)
|
||||
└─ …one child per section, one grandchild per nested group…
|
||||
|
||||
The instance holds ONLY overridden fields (deltas — the admin plugin removes an attribute the
|
||||
moment it equals the engine default), so unset fields keep following engine defaults across
|
||||
updates. Resolution order: engine defaults (defineSection at require) → game
|
||||
`Config.override(...)` → this instance (HIGHEST priority, applied by `EngineConfig.apply()` as
|
||||
the first step of `SurvivorCore.start()` / `startClient()`). Apply-once at boot: edits take
|
||||
effect on the NEXT Play/server start (several systems cache their section at start, so a live
|
||||
watch would only half-apply — this contract is honest).
|
||||
|
||||
`EngineConfig.SECTIONS` is the schema the admin plugin renders (sections → groups → typed
|
||||
fields) and the allow-list `apply()` walks — plugin and engine can never disagree. Field kinds:
|
||||
number | boolean | string | enum | color3 (native Color3 attribute) | font (attribute holds an
|
||||
Enum.Font NAME, e.g. "GothamMedium"). Invalid saved values are warned about and SKIPPED — a bad
|
||||
attribute must never break boot. SurvivalStats is deliberately absent (it has its own locked
|
||||
instance + editor; see StatConfig).
|
||||
]]
|
||||
|
||||
local Config = require(script.Parent.Parent.foundation.Config)
|
||||
|
||||
local MovementConfig = require(script.Parent.MovementConfig)
|
||||
local ConsequenceConfig = require(script.Parent.ConsequenceConfig)
|
||||
local InventoryConfig = require(script.Parent.InventoryConfig)
|
||||
local UiConfig = require(script.Parent.UiConfig)
|
||||
local HarvestingConfig = require(script.Parent.HarvestingConfig)
|
||||
local CraftingConfig = require(script.Parent.CraftingConfig)
|
||||
local MobsConfig = require(script.Parent.MobsConfig)
|
||||
local CombatConfig = require(script.Parent.CombatConfig)
|
||||
local QuestsConfig = require(script.Parent.QuestsConfig)
|
||||
local AchievementsConfig = require(script.Parent.AchievementsConfig)
|
||||
local LootBagsConfig = require(script.Parent.LootBagsConfig)
|
||||
|
||||
local EngineConfig = {}
|
||||
|
||||
EngineConfig.INSTANCE_NAME = "SurvivorCoreEngineConfig"
|
||||
|
||||
export type Field = {
|
||||
attr: string,
|
||||
kind: string, -- "number" | "boolean" | "string" | "enum" | "color3" | "font"
|
||||
label: string,
|
||||
min: number?,
|
||||
max: number?,
|
||||
integer: boolean?,
|
||||
choices: { string }?,
|
||||
check: string?, -- "keycode" | "assetId"
|
||||
note: string?,
|
||||
}
|
||||
|
||||
export type Group = {
|
||||
name: string?, -- nil = attributes live on the section child itself
|
||||
label: string,
|
||||
fields: { Field },
|
||||
}
|
||||
|
||||
export type Section = {
|
||||
id: string, -- Config section name AND instance child name
|
||||
title: string,
|
||||
note: string?, -- rendered by the plugin as a dim note row (e.g. code-managed fields)
|
||||
groups: { Group },
|
||||
}
|
||||
|
||||
local function num(attr: string, label: string, min: number?, max: number?, integer: boolean?): Field
|
||||
return { attr = attr, kind = "number", label = label, min = min, max = max, integer = integer }
|
||||
end
|
||||
|
||||
local function boolean(attr: string, label: string): Field
|
||||
return { attr = attr, kind = "boolean", label = label }
|
||||
end
|
||||
|
||||
local function color(attr: string, label: string): Field
|
||||
return { attr = attr, kind = "color3", label = label }
|
||||
end
|
||||
|
||||
EngineConfig.SECTIONS = {
|
||||
{
|
||||
id = "Movement",
|
||||
title = "Movement",
|
||||
groups = {
|
||||
{
|
||||
name = "Movement",
|
||||
label = "Speeds",
|
||||
fields = {
|
||||
num("WalkSpeed", "Walk speed", 0),
|
||||
num("SprintSpeed", "Sprint speed", 0),
|
||||
num("ExhaustedSpeed", "Exhausted speed", 0),
|
||||
num("JumpPower", "Jump power", 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Energy",
|
||||
label = "Energy",
|
||||
fields = {
|
||||
num("Max", "Max energy", 1),
|
||||
num("SprintDrainPerSecond", "Sprint drain / s", 0),
|
||||
num("JumpCost", "Jump cost", 0),
|
||||
num("RegenPerSecond", "Regen / s", 0),
|
||||
num("RegenDelaySeconds", "Regen delay (s)", 0),
|
||||
num("MinToJump", "Min energy to jump", 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Health",
|
||||
label = "Health feedback",
|
||||
fields = {
|
||||
num("HeartbeatStartRatio", "Heartbeat starts at (0-1)", 0, 1),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Audio",
|
||||
label = "Audio feedback",
|
||||
fields = {
|
||||
num("BreathingMaxVolume", "Breathing max volume", 0, 10),
|
||||
num("BreathingMinSpeed", "Breathing min speed", 0.05),
|
||||
num("BreathingMaxSpeed", "Breathing max speed", 0.05),
|
||||
num("HeartbeatMaxVolume", "Heartbeat max volume", 0, 10),
|
||||
num("HeartbeatMinSpeed", "Heartbeat min speed", 0.05),
|
||||
num("HeartbeatMaxSpeed", "Heartbeat max speed", 0.05),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Assets",
|
||||
label = "Assets (rbxassetid://…)",
|
||||
fields = {
|
||||
{ attr = "Vignette", kind = "string", label = "Low-stat vignette", check = "assetId" },
|
||||
{ attr = "Breathing", kind = "string", label = "Breathing sound", check = "assetId" },
|
||||
{ attr = "Heartbeat", kind = "string", label = "Heartbeat sound", check = "assetId" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Combat",
|
||||
title = "Combat",
|
||||
groups = {
|
||||
{
|
||||
label = "Melee",
|
||||
fields = {
|
||||
num("MeleeRange", "Melee range (studs)", 0.5),
|
||||
num("MeleeCooldown", "Melee cooldown (s)", 0),
|
||||
boolean("RequireLineOfSight", "Require line of sight"),
|
||||
boolean("FriendlyFire", "Friendly fire"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Bow",
|
||||
label = "Bow & arrows",
|
||||
fields = {
|
||||
num("Gravity", "Arrow gravity", 0),
|
||||
num("ProjectileSpeed", "Projectile speed", 1),
|
||||
num("MaxRange", "Max range (studs)", 1),
|
||||
num("MinDrawDamageMult", "Min-draw damage mult (0-1)", 0, 1),
|
||||
num("StepSize", "Sim step size (studs)", 1),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Mobs",
|
||||
title = "Mobs & AI",
|
||||
groups = {
|
||||
{
|
||||
label = "AI defaults",
|
||||
fields = {
|
||||
num("TickRate", "AI tick rate (s)", 0.05),
|
||||
num("DefaultAggroRange", "Aggro range", 0),
|
||||
num("DefaultLeashRange", "Leash range", 0),
|
||||
num("DefaultAttackRange", "Attack range", 0),
|
||||
num("DefaultAttackDamage", "Attack damage", 0),
|
||||
num("DefaultAttackCooldown", "Attack cooldown (s)", 0),
|
||||
num("DefaultWanderRadius", "Wander radius", 0),
|
||||
boolean("RequireLineOfSight", "Require line of sight"),
|
||||
num("RespawnSeconds", "Respawn after (s)", 0),
|
||||
num("CorpseSeconds", "Corpse lingers (s)", 0),
|
||||
num("DefaultCarcassSeconds", "Carcass lingers (s)", 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Harvesting",
|
||||
title = "Harvesting",
|
||||
groups = {
|
||||
{
|
||||
label = "Tool swings",
|
||||
fields = {
|
||||
num("SwingRange", "Swing range (studs)", 0.5),
|
||||
num("SwingCooldown", "Swing cooldown (s)", 0),
|
||||
boolean("RequireLineOfSight", "Require line of sight"),
|
||||
boolean("ShowHealthBar", "Show node health bar"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Crafting",
|
||||
title = "Crafting",
|
||||
groups = {
|
||||
{
|
||||
label = "Craft times",
|
||||
fields = {
|
||||
num("BaseCraftTime", "Base craft time (s)", 0),
|
||||
num("TimePerIngredient", "Time per ingredient (s)", 0),
|
||||
num("MaxCraftTime", "Max craft time (s)", 0.1),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Inventory",
|
||||
title = "Inventory",
|
||||
note = 'Equip slots & auto-hotbar categories are lists — set them from code via Config.override("Inventory", …).',
|
||||
groups = {
|
||||
{
|
||||
label = "Capacity & hotbar",
|
||||
fields = {
|
||||
num("BasePocketSlots", "Base pocket slots", 1, nil, true),
|
||||
num("BasePocketWeight", "Base carry weight", 0),
|
||||
num("HotbarSize", "Hotbar slots", 1, 9, true),
|
||||
num("UseCooldownSeconds", "Item-use cooldown (s)", 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Consequences",
|
||||
title = "Consequences",
|
||||
groups = {
|
||||
{
|
||||
name = "HealthDrainPerSecond",
|
||||
label = "Health drain / s",
|
||||
fields = {
|
||||
num("Starving", "While starving", 0),
|
||||
num("Dehydrated", "While dehydrated", 0),
|
||||
num("PoisonAtMax", "At max poison", 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Affliction",
|
||||
label = "Affliction build-up / s",
|
||||
fields = {
|
||||
num("BleedRatePerSecond", "Bleed rate", 0),
|
||||
num("PoisonRatePerSecond", "Poison rate", 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "LootBags",
|
||||
title = "Loot bags",
|
||||
groups = {
|
||||
{
|
||||
label = "Death drops",
|
||||
fields = {
|
||||
boolean("Enabled", "Drop a bag on death"),
|
||||
boolean("DropEquipment", "Drop worn equipment too"),
|
||||
num("LifetimeSeconds", "Bag lifetime (s)", 1),
|
||||
num("InteractRange", "Loot prompt range (studs)", 1),
|
||||
boolean("ShowCountdown", "Show countdown"),
|
||||
boolean("OwnerBeacon", "Owner beacon"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Quests",
|
||||
title = "Quests",
|
||||
groups = {
|
||||
{
|
||||
label = "Quests",
|
||||
fields = {
|
||||
num("MaxActive", "Max active (0 = unlimited)", 0, nil, true),
|
||||
boolean("Toasts", "Completion toasts"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "Achievements",
|
||||
title = "Achievements",
|
||||
groups = {
|
||||
{
|
||||
label = "Achievements",
|
||||
fields = {
|
||||
boolean("Toasts", "Unlock toasts"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "UI",
|
||||
title = "UI & theme",
|
||||
groups = {
|
||||
{
|
||||
label = "General",
|
||||
fields = {
|
||||
boolean("ReclaimCoreKeys", "Reclaim core Roblox keys"),
|
||||
num("DragThreshold", "Drag threshold (px)", 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Keybinds",
|
||||
label = "Keybinds (Enum.KeyCode names)",
|
||||
fields = {
|
||||
{ attr = "Menu", kind = "string", label = "Menu", check = "keycode" },
|
||||
{ attr = "Character", kind = "string", label = "Character", check = "keycode" },
|
||||
{ attr = "Codex", kind = "string", label = "Codex", check = "keycode" },
|
||||
{ attr = "Achievements", kind = "string", label = "Achievements", check = "keycode" },
|
||||
{ attr = "Quests", kind = "string", label = "Quests", check = "keycode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Chat",
|
||||
label = "Chat placement",
|
||||
fields = {
|
||||
boolean("Reposition", "Reposition chat"),
|
||||
{
|
||||
attr = "Horizontal",
|
||||
kind = "enum",
|
||||
label = "Horizontal",
|
||||
choices = { "Left", "Center", "Right" },
|
||||
},
|
||||
{ attr = "Vertical", kind = "enum", label = "Vertical", choices = { "Top", "Center", "Bottom" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Theme",
|
||||
label = "Theme",
|
||||
fields = {
|
||||
color("PanelColor", "Panel color"),
|
||||
num("PanelTransparency", "Panel transparency (0-1)", 0, 1),
|
||||
color("SlotColor", "Slot color"),
|
||||
num("CornerRadius", "Corner radius", 0, nil, true),
|
||||
color("StrokeColor", "Stroke color"),
|
||||
num("StrokeTransparency", "Stroke transparency (0-1)", 0, 1),
|
||||
color("Text", "Text color"),
|
||||
color("TextSecondary", "Secondary text color"),
|
||||
color("Accent", "Accent color"),
|
||||
color("Bad", "Bad/danger color"),
|
||||
color("Ok", "Ok/good color"),
|
||||
{ attr = "Font", kind = "font", label = "Font" },
|
||||
{ attr = "FontBold", kind = "font", label = "Bold font" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Inventory",
|
||||
label = "Inventory grid",
|
||||
fields = {
|
||||
num("SlotSize", "Slot size (px)", 8),
|
||||
num("SlotPadding", "Slot padding (px)", 0),
|
||||
num("Columns", "Columns", 1, nil, true),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "Hotbar",
|
||||
label = "Hotbar",
|
||||
fields = {
|
||||
num("SlotSize", "Slot size (px)", 8),
|
||||
num("SlotPadding", "Slot padding (px)", 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "DisplayOrder",
|
||||
label = "Display order (z-layers)",
|
||||
fields = {
|
||||
num("Menu", "Menu", nil, nil, true),
|
||||
num("Hotbar", "Hotbar", nil, nil, true),
|
||||
num("ClickOutside", "Click-outside shield", nil, nil, true),
|
||||
num("DragGhost", "Drag ghost", nil, nil, true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local DEFAULTS_BY_SECTION: { [string]: any } = {
|
||||
Movement = MovementConfig.DEFAULTS,
|
||||
Combat = CombatConfig.DEFAULTS,
|
||||
Mobs = MobsConfig.DEFAULTS,
|
||||
Harvesting = HarvestingConfig.DEFAULTS,
|
||||
Crafting = CraftingConfig.DEFAULTS,
|
||||
Inventory = InventoryConfig.DEFAULTS,
|
||||
Consequences = ConsequenceConfig.DEFAULTS,
|
||||
LootBags = LootBagsConfig.DEFAULTS,
|
||||
Quests = QuestsConfig.DEFAULTS,
|
||||
Achievements = AchievementsConfig.DEFAULTS,
|
||||
UI = UiConfig.DEFAULTS,
|
||||
}
|
||||
|
||||
local function deepCopy(t: any): any
|
||||
if typeof(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
local c = {}
|
||||
for k, v in t do
|
||||
c[k] = deepCopy(v)
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
-- The engine defaults for one section (deep copy — safe for the plugin to display/compare).
|
||||
function EngineConfig.getDefaults(sectionId: string): any
|
||||
local defaults = DEFAULTS_BY_SECTION[sectionId]
|
||||
return if defaults then deepCopy(defaults) else nil
|
||||
end
|
||||
|
||||
-- Validate + convert one saved attribute value per its field spec. Returns (ok, value?, err?).
|
||||
local function resolveValue(field: Field, raw: any): (boolean, any, string?)
|
||||
if field.kind == "number" then
|
||||
if typeof(raw) ~= "number" then
|
||||
return false, nil, "expected a number"
|
||||
end
|
||||
if raw ~= raw or raw == math.huge or raw == -math.huge then
|
||||
return false, nil, "expected a finite number"
|
||||
end
|
||||
local v = raw
|
||||
if field.min then
|
||||
v = math.max(v, field.min)
|
||||
end
|
||||
if field.max then
|
||||
v = math.min(v, field.max)
|
||||
end
|
||||
if field.integer then
|
||||
v = math.floor(v + 0.5)
|
||||
end
|
||||
return true, v
|
||||
elseif field.kind == "boolean" then
|
||||
if typeof(raw) ~= "boolean" then
|
||||
return false, nil, "expected a boolean"
|
||||
end
|
||||
return true, raw
|
||||
elseif field.kind == "string" then
|
||||
if typeof(raw) ~= "string" then
|
||||
return false, nil, "expected a string"
|
||||
end
|
||||
if field.check == "keycode" then
|
||||
local ok, keyCode = pcall(function()
|
||||
return (Enum.KeyCode :: any)[raw]
|
||||
end)
|
||||
if not ok or typeof(keyCode) ~= "EnumItem" then
|
||||
return false, nil, `'{raw}' is not an Enum.KeyCode name`
|
||||
end
|
||||
elseif field.check == "assetId" then
|
||||
if raw ~= "" and string.match(raw, "^rbxassetid://%d+$") == nil then
|
||||
-- Accept but call it out — creators sometimes paste other valid content URIs.
|
||||
warn(`[SurvivorCore.EngineConfig] '{field.attr}' = '{raw}' doesn't look like rbxassetid://<id>`)
|
||||
end
|
||||
end
|
||||
return true, raw
|
||||
elseif field.kind == "enum" then
|
||||
if typeof(raw) ~= "string" then
|
||||
return false, nil, "expected a string"
|
||||
end
|
||||
local choices: { string } = field.choices or {}
|
||||
for _, choice in choices do
|
||||
if choice == raw then
|
||||
return true, raw
|
||||
end
|
||||
end
|
||||
return false, nil, `'{raw}' is not one of: {table.concat(choices, ", ")}`
|
||||
elseif field.kind == "color3" then
|
||||
if typeof(raw) ~= "Color3" then
|
||||
return false, nil, "expected a Color3"
|
||||
end
|
||||
return true, raw
|
||||
elseif field.kind == "font" then
|
||||
if typeof(raw) ~= "string" then
|
||||
return false, nil, "expected an Enum.Font name"
|
||||
end
|
||||
local ok, font = pcall(function()
|
||||
return (Enum.Font :: any)[raw]
|
||||
end)
|
||||
if not ok or typeof(font) ~= "EnumItem" then
|
||||
return false, nil, `'{raw}' is not an Enum.Font name`
|
||||
end
|
||||
return true, font
|
||||
end
|
||||
return false, nil, `unknown field kind '{field.kind}'`
|
||||
end
|
||||
|
||||
-- Read the persisted instance (if any) and layer its deltas onto every section via
|
||||
-- Config.override. Call once, before systems boot, on BOTH server and client. Invalid values
|
||||
-- warn and are skipped — never fatal. Returns counts for testing.
|
||||
function EngineConfig.apply(): { sections: number, fields: number, warnings: { string } }
|
||||
local result = { sections = 0, fields = 0, warnings = {} }
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local instance = ReplicatedStorage:FindFirstChild(EngineConfig.INSTANCE_NAME)
|
||||
if not instance then
|
||||
return result
|
||||
end
|
||||
|
||||
for _, section in EngineConfig.SECTIONS do
|
||||
local sectionChild = instance:FindFirstChild(section.id)
|
||||
if not sectionChild then
|
||||
continue
|
||||
end
|
||||
local overrides: { [string]: any } = {}
|
||||
local sectionFields = 0
|
||||
for _, group in section.groups do
|
||||
local node: Instance? = if group.name then sectionChild:FindFirstChild(group.name) else sectionChild
|
||||
if not node then
|
||||
continue
|
||||
end
|
||||
for _, field in group.fields do
|
||||
local raw = node:GetAttribute(field.attr)
|
||||
if raw == nil then
|
||||
continue
|
||||
end
|
||||
local ok, value, err = resolveValue(field, raw)
|
||||
if not ok then
|
||||
local path = `{section.id}{if group.name then "." .. group.name else ""}.{field.attr}`
|
||||
local message = `[SurvivorCore.EngineConfig] ignoring '{path}': {err}`
|
||||
warn(message)
|
||||
table.insert(result.warnings, message)
|
||||
continue
|
||||
end
|
||||
if group.name then
|
||||
local sub = overrides[group.name]
|
||||
if sub == nil then
|
||||
sub = {}
|
||||
overrides[group.name] = sub
|
||||
end
|
||||
sub[field.attr] = value
|
||||
else
|
||||
overrides[field.attr] = value
|
||||
end
|
||||
sectionFields += 1
|
||||
end
|
||||
end
|
||||
if sectionFields > 0 then
|
||||
Config.override(section.id, overrides)
|
||||
result.sections += 1
|
||||
result.fields += sectionFields
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
return EngineConfig
|
||||
Reference in New Issue
Block a user