mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
Merge pull request #100 from TemujinCalidius/dev
release: v0.10.0 → main
This commit is contained in:
@@ -5,6 +5,33 @@ 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`.
|
||||
|
||||
## 0.10.0 — 2026-08-01
|
||||
|
||||
### Changed
|
||||
- **Demo: one switch for what the world contains** — `demo/server/Stage.luau` toggles the gather
|
||||
field + quest post, the mobs, the starting inventory and the hand-test prop rows independently.
|
||||
The **test stations now default OFF**: that labelled row of DAMAGE / FEED / POISON / BLEED
|
||||
prompts (plus the item pickups) is scaffolding for working *on* the engine, and it crowded the
|
||||
spawn area for everyone else. Flip any flag on to get that piece back; turn them all off for a
|
||||
clean stage when filming or when building your own world on the engine. Content *definitions*
|
||||
register regardless, so the admin plugin's pickers still list the demo's items and mobs.
|
||||
|
||||
### Added
|
||||
- **Build: no-code world objects** (#11) — select a Part or Model in Studio, answer **"what is this
|
||||
object?"**, and fill a form: it becomes a gatherable node, a mob or a quest giver, tag and
|
||||
attributes applied for you. The form is generated from the engine's own **component schema**, so
|
||||
fields, defaults and help text can never drift from what runs — and **any component that declares
|
||||
a schema gets a form for free, with no per-component UI code**. Fields that reference authored
|
||||
content (a resource, a mob type, a quest) offer a picker instead of asking you to remember ids;
|
||||
writes are deltas-only (an attribute exists only where you diverged from the default); *Change
|
||||
type* and *Clear* are the honest inverses; multi-select applies in bulk as one undo step.
|
||||
- **`Components.define` accepts an attribute schema** — `attributes` may now be an array of
|
||||
`{ attr, kind, label, default, help, choices, min/max, group, ref }` specs (plus a `display` block
|
||||
naming the component for the chooser) instead of the `attr = default` shorthand. **Both forms bind
|
||||
identically**; existing components and game code are unaffected. New `Components.getSchema` /
|
||||
`listSchemas`, and `src/components/Schema.luau` — a dependency-free module the Studio plugin reads
|
||||
live at edit time. See [docs/extending.md](docs/extending.md).
|
||||
|
||||
## 0.9.0 — 2026-07-30
|
||||
|
||||
### Security
|
||||
|
||||
+4
-1
@@ -80,7 +80,10 @@ components, hooks, and the foundation. See [Architecture Overview](#architecture
|
||||
- **Registries** — developers call `register()` from code (`Items`, `Recipes`, `Stats`,
|
||||
`Mobs`, …).
|
||||
- **Components** — creators tag their own objects and set Attributes (`Gatherable`, and the
|
||||
component family that follows).
|
||||
component family that follows). **A new creator-facing component must declare an attribute
|
||||
schema** (a `Schema.COMPONENTS` entry in `src/components/Schema.luau`, plus a `display` block),
|
||||
so the admin plugin's **Build** page can render its setup form — no creator should have to
|
||||
memorise attribute names. See [docs/extending.md](docs/extending.md#declaring-a-schema-so-the-builder-can-render-a-form).
|
||||
- **Extend via Hooks, don't fork.** Game-specific flourish (felling physics, station VFX,
|
||||
custom drops) belongs in a `Hooks.on(...)` handler in the *game*, not baked into the engine.
|
||||
If you need a new extension point, add a `Hooks.run("…")` call and document it.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
> **Status: v0.9.0 — pre-release.** The core survival loop is in and working; the engine is
|
||||
> **Status: v0.10.0 — pre-release.** The core survival loop is in and working; the engine is
|
||||
> being grown toward v1.0 and APIs may still shift. Production-tested in
|
||||
> [The Counter Earth](https://thecounterearth.com).
|
||||
|
||||
@@ -71,7 +71,7 @@ into `ReplicatedStorage`, or add it via [Wally](https://wally.run):
|
||||
```toml
|
||||
# wally.toml
|
||||
[dependencies]
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.10.0"
|
||||
```
|
||||
|
||||
Working from source? Clone and `rojo serve` the `demo.project.json` place.
|
||||
|
||||
@@ -12,6 +12,9 @@ local Players = game:GetService("Players")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
||||
|
||||
-- What this demo places in the world — see demo/server/Stage.luau to turn any of it off.
|
||||
local STAGE = require(script.Parent.Stage)
|
||||
|
||||
-- 1. Programmatic content -----------------------------------------------------
|
||||
-- NOTE: the `icon` ids below are PLACEHOLDERS (the engine's stat icons) so the demo grid
|
||||
-- isn't empty. A real game registers proper item icons (per-item `icon` or the "ItemIcons"
|
||||
@@ -416,10 +419,12 @@ local function seedPlayer(player: Player)
|
||||
player:SetAttribute("EquipSlot_Head", "straw_hat") -- a pre-filled equipment slot
|
||||
player:SetAttribute("EquipSlot_Back", "reed_satchel") -- equipped for +slots / +carry weight
|
||||
end
|
||||
for _, player in Players:GetPlayers() do
|
||||
if STAGE.SeedInventory then
|
||||
for _, player in Players:GetPlayers() do
|
||||
seedPlayer(player)
|
||||
end
|
||||
Players.PlayerAdded:Connect(seedPlayer)
|
||||
end
|
||||
Players.PlayerAdded:Connect(seedPlayer)
|
||||
|
||||
SurvivorCore.start()
|
||||
|
||||
@@ -509,10 +514,12 @@ local function buildReed(position: Vector3)
|
||||
reed.Parent = Workspace
|
||||
end
|
||||
|
||||
buildTree(Vector3.new(10, 0, 28))
|
||||
buildTree(Vector3.new(18, 0, 28))
|
||||
buildReed(Vector3.new(-8, 0, 28))
|
||||
buildReed(Vector3.new(-12, 0, 28))
|
||||
if STAGE.WorldContent then
|
||||
buildTree(Vector3.new(10, 0, 28))
|
||||
buildTree(Vector3.new(18, 0, 28))
|
||||
buildReed(Vector3.new(-8, 0, 28))
|
||||
buildReed(Vector3.new(-12, 0, 28))
|
||||
end
|
||||
|
||||
-- Reaction juice (per resource type) — pure creator content via the engine's reaction API.
|
||||
local SWAY = TweenInfo.new(0.1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, 0, true)
|
||||
@@ -582,9 +589,11 @@ buildWeaponTemplates()
|
||||
|
||||
-- Husks live out past their aggro+wander reach of spawn — new players shouldn't get farmed at
|
||||
-- the spawn point before they've picked up a weapon (they'll still hunt you in their territory).
|
||||
SurvivorCore.Mobs.spawn("husk", CFrame.new(70, 5, 45), { respawn = true })
|
||||
SurvivorCore.Mobs.spawn("husk", CFrame.new(82, 5, 58), { respawn = true })
|
||||
SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18))
|
||||
if STAGE.Mobs then
|
||||
SurvivorCore.Mobs.spawn("husk", CFrame.new(70, 5, 45), { respawn = true })
|
||||
SurvivorCore.Mobs.spawn("husk", CFrame.new(82, 5, 58), { respawn = true })
|
||||
SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18))
|
||||
end
|
||||
|
||||
-- The quest-giver post: offers `slay_husk` (hold E to accept; return to turn in). Any mesh works —
|
||||
-- tag it "QuestGiver" + set Quest — this demo just uses a marked wooden post.
|
||||
@@ -608,7 +617,9 @@ local function buildQuestPost(position: Vector3)
|
||||
CollectionService:AddTag(post, "QuestGiver")
|
||||
post.Parent = Workspace
|
||||
end
|
||||
buildQuestPost(Vector3.new(8, 0, 8))
|
||||
if STAGE.WorldContent then
|
||||
buildQuestPost(Vector3.new(8, 0, 8))
|
||||
end
|
||||
|
||||
-- Flourish hooks: print quest + achievement milestones to the output.
|
||||
SurvivorCore.Hooks.on("quest:completed", function(ctx)
|
||||
|
||||
@@ -14,6 +14,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
|
||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
||||
local Stage = require(script.Parent.Stage)
|
||||
|
||||
local PICKUPS = {
|
||||
{ label = "PICK UP Stone Axe", item = "stone_axe", amount = 1, color = Color3.fromRGB(120, 130, 140) },
|
||||
@@ -67,8 +68,10 @@ local function buildPickup(def: any, position: Vector3)
|
||||
end)
|
||||
end
|
||||
|
||||
local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations
|
||||
for i, def in PICKUPS do
|
||||
if Stage.TestStations then
|
||||
local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations
|
||||
for i, def in PICKUPS do
|
||||
local x = (i - (#PICKUPS + 1) / 2) * 4
|
||||
buildPickup(def, BASE + Vector3.new(x, 0, 0))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Demo stage toggles — ONE place to control what the demo places in the world.
|
||||
|
||||
The demo furnishes a world so the engine has something to DO out of the box. Turn any of it
|
||||
off for a CLEAN STAGE: filming, screenshots, or building your own world on top of the engine
|
||||
without the demo's furniture in shot.
|
||||
|
||||
Content DEFINITIONS (items, recipes, quests, achievements, mob types) are always registered
|
||||
regardless — so the admin plugin's pickers still list `reed_bush`, `husk`, `boar` and friends,
|
||||
and you can point your own objects at real content. These flags only control what gets PLACED
|
||||
in the world and what the player starts with.
|
||||
|
||||
Set them all to false and pressing Play gives you an empty baseplate.
|
||||
]]
|
||||
|
||||
return {
|
||||
WorldContent = true, -- the gather field (trees + reeds) and the quest post
|
||||
Mobs = true, -- the husks + the boar
|
||||
SeedInventory = true, -- the starting items, hotbar pins and worn equipment
|
||||
|
||||
-- OFF by default: these are hand-test scaffolding (a labelled row of DAMAGE / FEED / POISON /
|
||||
-- BLEED prompts, plus item pickups) for exercising the stat and inventory APIs. Useful when
|
||||
-- you're working ON the engine, but they crowd the spawn area for everyone else. Flip on when
|
||||
-- you want to poke the survival systems by hand.
|
||||
TestStations = false,
|
||||
}
|
||||
@@ -16,6 +16,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
|
||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
||||
local Stage = require(script.Parent.Stage)
|
||||
|
||||
local POISON_SOURCE = "TestStation:poison"
|
||||
local BLEED_SOURCE = "TestStation:bleed"
|
||||
@@ -149,8 +150,10 @@ end
|
||||
-- (The engine syncs the "Health" stat to the Humanoid + resets stats on respawn now, so the
|
||||
-- DAMAGE part's real damage shows on the HUD and death/respawn comes out clean on its own.)
|
||||
|
||||
local BASE = Vector3.new(0, 4, -16)
|
||||
for i, def in STATIONS do
|
||||
if Stage.TestStations then
|
||||
local BASE = Vector3.new(0, 4, -16)
|
||||
for i, def in STATIONS do
|
||||
local x = (i - (#STATIONS + 1) / 2) * 4
|
||||
buildStation(def, BASE + Vector3.new(x, 0, 0))
|
||||
end
|
||||
end
|
||||
|
||||
+45
-1
@@ -5,6 +5,9 @@ Attributes in the Explorer. One **SurvivorCore › Studio** toolbar button opens
|
||||
window** (drag-dock it anywhere) with a sidebar of editors:
|
||||
|
||||
- **Overview** — is the engine synced, what content exists (click through), how the model works.
|
||||
- **Build** — select a Part or Model in the viewport, answer **"what is this object?"**, and fill a
|
||||
form: it becomes a gatherable node, a mob, or a quest giver. No tags to memorise, no attribute
|
||||
names to type. See [Build: world objects](#build-world-objects) below.
|
||||
- **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig`
|
||||
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,
|
||||
@@ -28,7 +31,7 @@ This is the [Builder / Admin plugin](https://github.com/TemujinCalidius/Survivor
|
||||
> 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) · [SurvivorCore Studio — the no-code admin window](https://makertube.net/w/g4oySJeXD4Th7f1zYEu9Bz)
|
||||
> 📹 **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) · [SurvivorCore Studio — the no-code admin window](https://makertube.net/w/g4oySJeXD4Th7f1zYEu9Bz) · [Build — a plain Part into a working iron node](https://makertube.net/w/2kkyPbDWqoyuKcgbiCGwQG)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -66,6 +69,47 @@ the window.
|
||||
> Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring
|
||||
> the engine back.
|
||||
|
||||
## Build: world objects
|
||||
|
||||
The **Build** page is how a creator turns their *own* geometry into engine content. Select a Part or
|
||||
Model and it asks **"What is this object?"** — pick **Gatherable**, **Mob (creature)** or **Quest
|
||||
giver** and the plugin applies the tag for you, then renders that component's setup form.
|
||||
|
||||
The form is generated from the **engine's own component schema**
|
||||
([`src/components/Schema.luau`](../src/components/Schema.luau)) — the same source the engine binds
|
||||
from — so the fields, their defaults and their help text can never drift from what actually runs.
|
||||
**Any component that declares a schema gets a form here automatically; there is no per-component UI
|
||||
code.**
|
||||
|
||||
### Your first tree, in 60 seconds
|
||||
|
||||
1. **Author the resource.** *Content › Gatherables › + Create* → id `oak_tree`, yields item `wood`,
|
||||
HP 4.
|
||||
2. **Build the mesh.** Insert a Part (or your own tree model) and select it.
|
||||
3. **Say what it is.** Open **Build** → click the **Gatherable** card.
|
||||
4. **Point it at the resource.** In the form, click the **⌄** next to *Resource* until it reads
|
||||
`oak_tree` — that's it; item, HP, tool and yield all follow the entry.
|
||||
5. **Play.** Walk up and hold **E**. Wood lands in your inventory.
|
||||
|
||||
Change your mind later: **Change type** swaps the component (clearing the old one's settings), and
|
||||
**Clear** removes the tag and every attribute the engine added.
|
||||
|
||||
### How it behaves
|
||||
|
||||
- **Deltas only.** Applying a component writes **no attributes** — the object follows every default
|
||||
until you change something, and a changed field that you set back to its default is removed again.
|
||||
The ○/● dot next to each field tells you which is which.
|
||||
- **Pickers, not memory.** Fields that reference authored content (a resource, a mob type, a quest)
|
||||
offer a **⌄** that cycles the ids you've created in *Content*.
|
||||
- **Eligibility is enforced.** *Mob* needs a Model (Humanoid + PrimaryPart), so it's dimmed with the
|
||||
reason when a plain Part is selected.
|
||||
- **Multi-select** applies to every eligible object at once, as one undo step.
|
||||
- **Undo** — every apply, clear and field edit is a single Studio undo step.
|
||||
- **Needs SurvivorCore ≥ 0.9.** Against an older engine the page explains that instead of failing.
|
||||
|
||||
> Components a game defines **at runtime** (in its own code) can't appear here — the plugin reads a
|
||||
> static module in Edit mode, where your server scripts haven't run. Tag those by hand as before.
|
||||
|
||||
## Survival Stats
|
||||
|
||||
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
|
||||
|
||||
@@ -117,6 +117,44 @@ SurvivorCore.Components.define({
|
||||
tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by
|
||||
an internal `_scBound` attribute).
|
||||
|
||||
Attributes prefixed with **`_`** are engine-internal (the `_scBound` bind marker, and any values a
|
||||
component resolves and stashes for its server system). Never author them by hand.
|
||||
|
||||
### Declaring a schema (so the Builder can render a form)
|
||||
|
||||
`attributes` also accepts a **schema array**, which adds the kind, label, help text and options for
|
||||
each attribute. That's what lets the Studio plugin's **Build** page render a setup form for your
|
||||
component — select a part, pick your component, fill the fields — with **no UI code on your side**:
|
||||
|
||||
```lua
|
||||
SurvivorCore.Components.define({
|
||||
name = "Campfire",
|
||||
tag = "Campfire",
|
||||
display = {
|
||||
title = "Campfire",
|
||||
summary = "A fire players light for warmth and cooking.",
|
||||
instance = "any", -- or "Model" / "BasePart" — what the Builder will let you tag
|
||||
},
|
||||
attributes = {
|
||||
{ attr = "FuelSeconds", kind = "number", label = "Fuel (seconds)", default = 60, min = 0,
|
||||
help = "How long one load of fuel burns." },
|
||||
{ attr = "Lit", kind = "boolean", label = "Starts lit", default = false },
|
||||
},
|
||||
onSetup = function(instance, values)
|
||||
-- `values` is identical either way: the instance's attribute, else the default.
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
Both forms bind identically — the shorthand map is still supported and nothing changes for
|
||||
components that use it. Field `kind` is `number` | `boolean` | `string` | `enum` (with `choices`);
|
||||
a field may also carry `min`/`max`/`integer`, a `placeholder`, a `group` (form section), and `ref`
|
||||
(a content category whose authored ids the Builder offers as a picker). The engine's own components
|
||||
declare theirs in [`src/components/Schema.luau`](../src/components/Schema.luau).
|
||||
|
||||
**Convention:** every creator-facing component should declare a schema, so it is Builder-drivable
|
||||
rather than requiring the creator to know attribute names.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hooks — react to engine lifecycle events
|
||||
|
||||
@@ -34,7 +34,7 @@ Once published, add it to your game's `wally.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.10.0"
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
BuildAdmin — headless logic for the Build page (issue #11): turn a selected Part or Model into
|
||||
a SurvivorCore world object (a Gatherable node, a Mob, a quest giver) by applying its tag and
|
||||
attributes, and edit those attributes afterwards.
|
||||
|
||||
The SCHEMA comes from the ENGINE — readSchema() requires
|
||||
ReplicatedStorage.SurvivorCore.components.Schema live, so the plugin and the engine can never
|
||||
disagree about a component's fields. (That module is dependency-free precisely so the plugin can
|
||||
require it: the component modules themselves pull in server systems and must never be required
|
||||
in Edit mode.)
|
||||
|
||||
Writes are DELTAS-ONLY, the same rule as the Stats and Engine Config editors:
|
||||
• an attribute exists IFF its value differs from the component's default
|
||||
• typing the default back (or blanking the box) REMOVES the attribute
|
||||
• unset attributes keep following engine defaults across engine updates
|
||||
|
||||
NOTE the two namespaces: this module writes PascalCase INSTANCE attributes (`ItemId`, `HP`) on
|
||||
world objects. The Content editor writes lowercase DEF fields (`item`, `hp`) on
|
||||
SurvivorCoreContent Configurations. A `ref` on a field links them (pick a def id) — they are
|
||||
never the same thing.
|
||||
|
||||
No `plugin` global and no ChangeHistory here: the plugin main wraps every call in record().
|
||||
]]
|
||||
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local Selection = game:GetService("Selection")
|
||||
|
||||
local Field = require(script.Parent.Field)
|
||||
|
||||
local BuildAdmin = {}
|
||||
|
||||
local BOUND_ATTR = "_scBound" -- the engine's runtime bind marker
|
||||
|
||||
-- ── Schema (live from the engine) ────────────────────────────────────────────
|
||||
|
||||
local function findSchemaModule(): ModuleScript?
|
||||
local engine = ReplicatedStorage:FindFirstChild("SurvivorCore")
|
||||
local components = engine and engine:FindFirstChild("components")
|
||||
local module = components and components:FindFirstChild("Schema")
|
||||
if module and module:IsA("ModuleScript") then
|
||||
return module
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- { ok = true, source, components = {ComponentSchema}, byTag } | { ok = false, reason, components = {} }
|
||||
-- Never errors. Re-read on every refresh so a re-synced engine self-heals.
|
||||
function BuildAdmin.readSchema(): any
|
||||
local module = findSchemaModule()
|
||||
if not module then
|
||||
return {
|
||||
ok = false,
|
||||
components = {},
|
||||
reason = "SurvivorCore engine not found in this place (expected"
|
||||
.. " ReplicatedStorage.SurvivorCore.components.Schema — sync or insert the engine, then Refresh).",
|
||||
}
|
||||
end
|
||||
local ok, schema = pcall(require, module)
|
||||
if not ok or typeof(schema) ~= "table" or typeof(schema.COMPONENTS) ~= "table" then
|
||||
return {
|
||||
ok = false,
|
||||
components = {},
|
||||
reason = "Found the engine, but it declares no component schemas — Build needs"
|
||||
.. " SurvivorCore ≥ 0.9. Update the engine, then Refresh.",
|
||||
}
|
||||
end
|
||||
|
||||
-- Build the ordered list defensively: ORDER may name an entry that no longer exists, and a
|
||||
-- NEWER engine may add entries ORDER doesn't cover. Include both, ORDER first.
|
||||
local list, seen = {}, {}
|
||||
for _, name in schema.ORDER or {} do
|
||||
local entry = schema.COMPONENTS[name]
|
||||
if typeof(entry) == "table" and typeof(entry.tag) == "string" then
|
||||
seen[name] = true
|
||||
table.insert(list, entry)
|
||||
end
|
||||
end
|
||||
local rest = {}
|
||||
for name, entry in schema.COMPONENTS do
|
||||
if not seen[name] and typeof(entry) == "table" and typeof(entry.tag) == "string" then
|
||||
table.insert(rest, name)
|
||||
end
|
||||
end
|
||||
table.sort(rest)
|
||||
for _, name in rest do
|
||||
table.insert(list, schema.COMPONENTS[name])
|
||||
end
|
||||
|
||||
if #list == 0 then
|
||||
return { ok = false, components = {}, reason = "This engine declares no components." }
|
||||
end
|
||||
|
||||
local byTag = {}
|
||||
for _, entry in list do
|
||||
byTag[entry.tag] = entry
|
||||
end
|
||||
return { ok = true, source = module:GetFullName(), components = list, byTag = byTag }
|
||||
end
|
||||
|
||||
-- Attributes are only meaningful with a label/kind; tolerate a schema field we don't understand.
|
||||
local function specsOf(schema: any): { any }
|
||||
return if typeof(schema) == "table" and typeof(schema.attributes) == "table" then schema.attributes else {}
|
||||
end
|
||||
|
||||
function BuildAdmin.displayOf(schema: any): any
|
||||
local d = schema and schema.display
|
||||
if typeof(d) ~= "table" then
|
||||
return { title = (schema and schema.name) or "Component", summary = "" }
|
||||
end
|
||||
return d
|
||||
end
|
||||
|
||||
-- ── Selection ────────────────────────────────────────────────────────────────
|
||||
|
||||
function BuildAdmin.classify(instance: Instance): string
|
||||
if instance:IsA("Model") then
|
||||
return "Model"
|
||||
elseif instance:IsA("BasePart") then
|
||||
return "BasePart"
|
||||
end
|
||||
return "other"
|
||||
end
|
||||
|
||||
-- Can this component be applied to this instance? Returns (ok, reason).
|
||||
function BuildAdmin.isEligible(instance: Instance, schema: any): (boolean, string?)
|
||||
local want: string = tostring(BuildAdmin.displayOf(schema).instance or "any")
|
||||
local got: string = BuildAdmin.classify(instance)
|
||||
if got == "other" then
|
||||
return false, "select a Part or a Model"
|
||||
end
|
||||
if want == "Model" then
|
||||
return got == "Model", "needs a Model (Humanoid + PrimaryPart)"
|
||||
elseif want == "BasePart" then
|
||||
return got == "BasePart", "needs a single Part"
|
||||
end
|
||||
return true, nil -- "any"
|
||||
end
|
||||
|
||||
-- { instances, eligible, count } — `eligible` are Parts/Models (anything a component could go on).
|
||||
function BuildAdmin.getSelection(): any
|
||||
local instances = Selection:Get()
|
||||
local eligible = {}
|
||||
for _, instance in instances do
|
||||
if BuildAdmin.classify(instance) ~= "other" then
|
||||
table.insert(eligible, instance)
|
||||
end
|
||||
end
|
||||
return { instances = instances, eligible = eligible, count = #instances }
|
||||
end
|
||||
|
||||
-- What IS this instance already? { schema?, tag?, unknownTags, multi, stale }
|
||||
-- multi = it carries more than one component tag (v1 assumes one; say so rather than guess)
|
||||
-- stale = it carries the runtime bind marker in Edit mode (a copy-paste out of a Play session);
|
||||
-- such an object silently never binds again, so every write path clears it.
|
||||
function BuildAdmin.identify(instance: Instance, schemaRead: any): any
|
||||
local found, unknown = {}, {}
|
||||
for _, tag in CollectionService:GetTags(instance) do
|
||||
local entry = schemaRead.ok and schemaRead.byTag[tag]
|
||||
if entry then
|
||||
table.insert(found, entry)
|
||||
else
|
||||
table.insert(unknown, tag)
|
||||
end
|
||||
end
|
||||
return {
|
||||
schema = found[1],
|
||||
tag = found[1] and found[1].tag,
|
||||
unknownTags = unknown,
|
||||
multi = #found > 1,
|
||||
stale = instance:GetAttribute(BOUND_ATTR) ~= nil,
|
||||
}
|
||||
end
|
||||
|
||||
-- ── Attribute read / write (deltas-only) ─────────────────────────────────────
|
||||
|
||||
-- { value, default, hasOverride, typeMismatch } — `typeMismatch` flags an attribute hand-set with
|
||||
-- the wrong type (e.g. HP = "3"), which would error inside the component's onSetup at bind time.
|
||||
function BuildAdmin.readField(instance: Instance, spec: any): any
|
||||
local raw = instance:GetAttribute(spec.attr)
|
||||
if raw == nil then
|
||||
return { value = spec.default, default = spec.default, hasOverride = false, typeMismatch = false }
|
||||
end
|
||||
local mismatch = spec.default ~= nil and typeof(raw) ~= typeof(spec.default)
|
||||
return { value = raw, default = spec.default, hasOverride = true, typeMismatch = mismatch }
|
||||
end
|
||||
|
||||
local function alive(instance: Instance?): boolean
|
||||
return instance ~= nil and instance.Parent ~= nil
|
||||
end
|
||||
|
||||
-- Set (or clear) one attribute. Blank input = reset. Writes ONLY when the value differs from the
|
||||
-- component default. Returns { ok, action = "write"|"remove"|"noop", error? }.
|
||||
function BuildAdmin.setField(instance: Instance, spec: any, raw: any): any
|
||||
if not alive(instance) then
|
||||
return { ok = false, action = "noop", error = "that object is gone" }
|
||||
end
|
||||
if typeof(raw) == "string" and string.match(raw, "^%s*$") then
|
||||
return BuildAdmin.resetField(instance, spec)
|
||||
end
|
||||
local coerced = Field.coerce(spec, raw)
|
||||
if not coerced.ok then
|
||||
return { ok = false, action = "noop", error = coerced.error }
|
||||
end
|
||||
if Field.equalsDefault(spec, coerced.value, spec.default) then
|
||||
return BuildAdmin.resetField(instance, spec)
|
||||
end
|
||||
instance:SetAttribute(spec.attr, coerced.value)
|
||||
instance:SetAttribute(BOUND_ATTR, nil) -- editing implies "bind me fresh next Play"
|
||||
return { ok = true, action = "write" }
|
||||
end
|
||||
|
||||
function BuildAdmin.resetField(instance: Instance, spec: any): any
|
||||
if not alive(instance) then
|
||||
return { ok = false, action = "noop", error = "that object is gone" }
|
||||
end
|
||||
if instance:GetAttribute(spec.attr) ~= nil then
|
||||
instance:SetAttribute(spec.attr, nil)
|
||||
instance:SetAttribute(BOUND_ATTR, nil)
|
||||
return { ok = true, action = "remove" }
|
||||
end
|
||||
return { ok = true, action = "noop" }
|
||||
end
|
||||
|
||||
-- Strip a component's declared attributes (and the engine's `_`-prefixed stamps) from an instance.
|
||||
local function stripComponent(instance: Instance, schema: any)
|
||||
for _, spec in specsOf(schema) do
|
||||
instance:SetAttribute(spec.attr, nil)
|
||||
end
|
||||
end
|
||||
|
||||
local function stripEngineStamps(instance: Instance)
|
||||
for name in instance:GetAttributes() do
|
||||
if string.sub(name, 1, 1) == "_" then
|
||||
instance:SetAttribute(name, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Make each instance this component: add its tag, remove any OTHER component's tag and that
|
||||
-- component's attributes, and clear engine stamps so it binds fresh on the next Play.
|
||||
-- Deltas-only: no attributes are written, so the object follows every default until you change one.
|
||||
-- Returns { applied, cleared, skipped }.
|
||||
function BuildAdmin.applyType(instances: { Instance }, schema: any, all: { any }): any
|
||||
local applied, cleared, skipped = 0, 0, 0
|
||||
for _, instance in instances do
|
||||
local ok = alive(instance) and BuildAdmin.isEligible(instance, schema)
|
||||
if not ok then
|
||||
skipped += 1
|
||||
continue
|
||||
end
|
||||
for _, other in all do
|
||||
if other.tag ~= schema.tag and CollectionService:HasTag(instance, other.tag) then
|
||||
CollectionService:RemoveTag(instance, other.tag)
|
||||
stripComponent(instance, other)
|
||||
cleared += 1
|
||||
end
|
||||
end
|
||||
stripEngineStamps(instance)
|
||||
if not CollectionService:HasTag(instance, schema.tag) then
|
||||
CollectionService:AddTag(instance, schema.tag)
|
||||
end
|
||||
applied += 1
|
||||
end
|
||||
return { applied = applied, cleared = cleared, skipped = skipped }
|
||||
end
|
||||
|
||||
-- The honest inverse of applyType: remove every known component tag, its attributes, and the
|
||||
-- engine's `_`-prefixed stamps. `extraTags` removes specific additional tags by name — the page
|
||||
-- passes the unknown tags it just displayed, so its "Clear" button actually clears what the user
|
||||
-- was shown. Tags are never removed speculatively: an unlisted tag may belong to another plugin.
|
||||
-- Returns { cleared }.
|
||||
function BuildAdmin.clear(instances: { Instance }, all: { any }, extraTags: { string }?): any
|
||||
local cleared = 0
|
||||
for _, instance in instances do
|
||||
if not alive(instance) then
|
||||
continue
|
||||
end
|
||||
local touched = false
|
||||
for _, schema in all do
|
||||
if CollectionService:HasTag(instance, schema.tag) then
|
||||
CollectionService:RemoveTag(instance, schema.tag)
|
||||
stripComponent(instance, schema)
|
||||
touched = true
|
||||
end
|
||||
end
|
||||
for _, tag in (extraTags or {}) :: { string } do
|
||||
if CollectionService:HasTag(instance, tag) then
|
||||
CollectionService:RemoveTag(instance, tag)
|
||||
touched = true
|
||||
end
|
||||
end
|
||||
if instance:GetAttribute(BOUND_ATTR) ~= nil then
|
||||
touched = true
|
||||
end
|
||||
stripEngineStamps(instance)
|
||||
if touched then
|
||||
cleared += 1
|
||||
end
|
||||
end
|
||||
return { cleared = cleared }
|
||||
end
|
||||
|
||||
-- How many instances in this place currently carry the component (drives the page footer).
|
||||
function BuildAdmin.countInPlace(schema: any): number
|
||||
return #CollectionService:GetTagged(schema.tag)
|
||||
end
|
||||
|
||||
return BuildAdmin
|
||||
@@ -0,0 +1,520 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
BuildAdminUi — the Build page (issue #11). Select a Part or Model in Studio and this asks
|
||||
"what is this object?", then renders that component's setup form from the engine's own schema.
|
||||
|
||||
States: engine missing · nothing selected · ineligible class · untagged (the chooser) · tagged
|
||||
(identity card + form) · unknown tag · multi-select (apply to all). Every write goes through the
|
||||
plugin main's record() wrapper, so each edit is one undo step.
|
||||
|
||||
Adding a component to the engine gives it a page here for free — there is no per-component UI
|
||||
code.
|
||||
]]
|
||||
|
||||
local Selection = game:GetService("Selection")
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
local FieldRow = require(script.Parent.FieldRow)
|
||||
|
||||
local BuildAdminUi = {}
|
||||
|
||||
local FOOTER_H = 36
|
||||
|
||||
-- Group the schema's attributes by their `group`, preserving first-appearance order.
|
||||
local function groupAttributes(specs: { any }, includeAdvanced: boolean): ({ string }, { [string]: { any } })
|
||||
local order, byGroup = {}, {}
|
||||
for _, spec in specs do
|
||||
if spec.advanced and not includeAdvanced then
|
||||
continue
|
||||
end
|
||||
local name = spec.group or "Settings"
|
||||
if not byGroup[name] then
|
||||
byGroup[name] = {}
|
||||
table.insert(order, name)
|
||||
end
|
||||
table.insert(byGroup[name], spec)
|
||||
end
|
||||
return order, byGroup
|
||||
end
|
||||
|
||||
local function countAdvanced(specs: { any }): number
|
||||
local n = 0
|
||||
for _, spec in specs do
|
||||
if spec.advanced then
|
||||
n += 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- One clickable "what is this?" card. Ineligible components render dim and inert, with the reason.
|
||||
-- The CARD ITSELF is the button when it's pickable: a full-size button parented INTO a panel would
|
||||
-- be laid out by the panel's UIListLayout as another row (no opt-out; ZIndex doesn't affect
|
||||
-- layout), leaving the card body dead and the button spilling onto the next card.
|
||||
local function chooserCard(schema: any, display: any, eligible: boolean, reason: string?, onPick): GuiObject
|
||||
local panel: any = if eligible then Theme.panelButton() else Theme.panel()
|
||||
local title = Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = display.title,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
TextColor3 = if eligible then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
LayoutOrder = 0,
|
||||
})
|
||||
title.Parent = panel
|
||||
|
||||
local summary = if eligible then display.summary else `{display.summary} — {reason or "not eligible"}`
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = summary,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
panel
|
||||
|
||||
if eligible then
|
||||
(panel :: TextButton).MouseButton1Click:Connect(function()
|
||||
onPick(schema)
|
||||
end)
|
||||
Theme.hover(panel, Theme.COLOR.PANEL, Theme.COLOR.PANEL_HOVER)
|
||||
end
|
||||
return panel
|
||||
end
|
||||
|
||||
-- actions = { applyType, clear, setField, resetField } — all record()-wrapped by the plugin main.
|
||||
function BuildAdminUi.mount(container: Frame, BuildAdmin: any, ContentAdmin: any, actions: any): { refresh: () -> () }
|
||||
local header = Theme.header(container, "Build")
|
||||
local scroll = Theme.scroll(32, FOOTER_H, 8)
|
||||
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,
|
||||
}) :: Frame
|
||||
footer.Parent = container
|
||||
local status = Theme.label({
|
||||
Size = UDim2.new(1, -20, 1, 0),
|
||||
Position = UDim2.fromOffset(10, 0),
|
||||
Text = "",
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
TextSize = 11,
|
||||
})
|
||||
status.Parent = footer
|
||||
|
||||
local chooserOpen = false -- "Change type" was clicked on an already-tagged instance
|
||||
local showAdvanced = false
|
||||
local refresh -- forward declaration (handlers below call it)
|
||||
|
||||
local function say(text: string)
|
||||
status.Text = text
|
||||
end
|
||||
|
||||
local function clearScroll()
|
||||
for _, child in scroll:GetChildren() do
|
||||
if not child:IsA("UIBase") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function note(text: string, order: number, color: Color3?)
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = text,
|
||||
TextColor3 = color or Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
}).Parent =
|
||||
scroll
|
||||
end
|
||||
|
||||
-- ── writes ────────────────────────────────────────────────────────────────
|
||||
local function applyType(instances: { Instance }, schema: any, all: { any })
|
||||
local res = actions.applyType(instances, schema, all)
|
||||
chooserOpen = false
|
||||
refresh()
|
||||
if typeof(res) == "table" then
|
||||
local title = BuildAdmin.displayOf(schema).title
|
||||
local msg = `✓ {res.applied} object(s) → {title}`
|
||||
if (res.cleared or 0) > 0 then
|
||||
msg ..= `, cleared {res.cleared} old component(s)`
|
||||
end
|
||||
if (res.skipped or 0) > 0 then
|
||||
msg ..= `, skipped {res.skipped} ineligible`
|
||||
end
|
||||
say(msg)
|
||||
end
|
||||
end
|
||||
|
||||
local function clearType(instances: { Instance }, all: { any }, extraTags: { string }?)
|
||||
local res = actions.clear(instances, all, extraTags)
|
||||
chooserOpen = false
|
||||
refresh()
|
||||
if typeof(res) == "table" then
|
||||
local n = res.cleared or 0
|
||||
say(if n > 0 then `✓ cleared {n} object(s)` else "nothing to clear on that object")
|
||||
end
|
||||
end
|
||||
|
||||
local function commitField(instance: Instance, spec: any, raw: any)
|
||||
local res = actions.setField(instance, spec, raw)
|
||||
refresh()
|
||||
if typeof(res) == "table" and res.ok == false and res.error then
|
||||
say(`✗ {spec.label}: {res.error}`)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── the form for one tagged instance ──────────────────────────────────────
|
||||
local function buildForm(instance: Instance, schema: any, startOrder: number): number
|
||||
local order = startOrder
|
||||
local specs = schema.attributes or {}
|
||||
local groups, byGroup = groupAttributes(specs, showAdvanced)
|
||||
|
||||
for _, groupName in groups do
|
||||
order += 1
|
||||
local panel = Theme.panel()
|
||||
panel.LayoutOrder = order
|
||||
panel.Parent = scroll
|
||||
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = groupName,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 13,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
panel
|
||||
|
||||
for i, spec in byGroup[groupName] do
|
||||
local state = BuildAdmin.readField(instance, spec)
|
||||
local choices = nil
|
||||
if spec.ref and ContentAdmin and ContentAdmin.listRoster then
|
||||
local ok, roster = pcall(ContentAdmin.listRoster, spec.ref)
|
||||
if ok and typeof(roster) == "table" then
|
||||
choices = {}
|
||||
for _, entry in roster do
|
||||
table.insert(choices, entry.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
local row = FieldRow.build({
|
||||
spec = spec,
|
||||
state = state,
|
||||
choices = choices,
|
||||
onCommit = function(raw)
|
||||
commitField(instance, spec, raw)
|
||||
end,
|
||||
onReset = function()
|
||||
actions.resetField(instance, spec)
|
||||
refresh()
|
||||
end,
|
||||
})
|
||||
row.LayoutOrder = i
|
||||
row.Parent = panel
|
||||
|
||||
-- First-run dead end: a field that points at authored content is useless until some
|
||||
-- exists, and the picker simply isn't there to hint at it. Say where to go, and
|
||||
-- offer to take them.
|
||||
if spec.ref and (not choices or #choices == 0) then
|
||||
local cat = ContentAdmin.CATEGORIES and ContentAdmin.CATEGORIES[spec.ref]
|
||||
local catTitle = (cat and cat.title) or spec.ref
|
||||
local jump = Theme.button({
|
||||
Size = UDim2.new(1, 0, 0, 22),
|
||||
Text = `No {catTitle} yet — create one →`,
|
||||
TextColor3 = Theme.COLOR.ACCENT,
|
||||
TextSize = 11,
|
||||
LayoutOrder = i,
|
||||
})
|
||||
jump.Parent = panel
|
||||
jump.MouseButton1Click:Connect(function()
|
||||
if actions.selectPage then
|
||||
actions.selectPage("content/" .. spec.ref)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local advanced = countAdvanced(specs)
|
||||
if advanced > 0 then
|
||||
order += 1
|
||||
local toggle = Theme.button({
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
Text = if showAdvanced then "Hide advanced" else `Show advanced ({advanced})`,
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
})
|
||||
toggle.Parent = scroll
|
||||
toggle.MouseButton1Click:Connect(function()
|
||||
showAdvanced = not showAdvanced
|
||||
refresh()
|
||||
end)
|
||||
end
|
||||
return order
|
||||
end
|
||||
|
||||
-- ── render ────────────────────────────────────────────────────────────────
|
||||
refresh = function()
|
||||
clearScroll()
|
||||
|
||||
local schemaRead = BuildAdmin.readSchema()
|
||||
if not schemaRead.ok then
|
||||
say("")
|
||||
note(schemaRead.reason or "Engine not found.", 1)
|
||||
return
|
||||
end
|
||||
local all = schemaRead.components
|
||||
|
||||
local sel = BuildAdmin.getSelection()
|
||||
|
||||
-- Nothing selected: explain, and list what this engine can build.
|
||||
if sel.count == 0 then
|
||||
say("")
|
||||
note("Select a Part or Model in the viewport or Explorer to set it up.", 1)
|
||||
local order = 1
|
||||
for _, schema in all do
|
||||
order += 1
|
||||
local display = BuildAdmin.displayOf(schema)
|
||||
local panel = Theme.panel()
|
||||
panel.LayoutOrder = order
|
||||
panel.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 18),
|
||||
Text = display.title,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 13,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
panel
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = display.summary,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
panel
|
||||
end
|
||||
note("Tip: Content › Gatherables › “+ Add to World” drops a ready-made one.", order + 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- Multi-selection: apply/clear in bulk, no per-attribute form.
|
||||
if sel.count > 1 then
|
||||
say(`{#sel.eligible} of {sel.count} selected can take a component`)
|
||||
note(`{sel.count} objects selected ({#sel.eligible} eligible). Applying affects all of them.`, 1)
|
||||
local order = 1
|
||||
for _, schema in all do
|
||||
order += 1
|
||||
local display = BuildAdmin.displayOf(schema)
|
||||
local btn = Theme.button({
|
||||
Size = UDim2.new(1, 0, 0, 26),
|
||||
Text = `Apply to all — {display.title}`,
|
||||
TextSize = 13,
|
||||
LayoutOrder = order,
|
||||
})
|
||||
btn.Parent = scroll
|
||||
btn.MouseButton1Click:Connect(function()
|
||||
applyType(sel.eligible, schema, all)
|
||||
end)
|
||||
end
|
||||
order += 1
|
||||
local clearBtn = Theme.button({
|
||||
Size = UDim2.new(1, 0, 0, 26),
|
||||
Text = "Clear all",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
TextSize = 13,
|
||||
LayoutOrder = order,
|
||||
})
|
||||
clearBtn.Parent = scroll
|
||||
clearBtn.MouseButton1Click:Connect(function()
|
||||
clearType(sel.eligible, all)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Exactly one selected.
|
||||
local instance = sel.instances[1]
|
||||
if BuildAdmin.classify(instance) == "other" then
|
||||
say("")
|
||||
note(`Selected a {instance.ClassName} — Build works on Parts and Models.`, 1)
|
||||
return
|
||||
end
|
||||
|
||||
local id = BuildAdmin.identify(instance, schemaRead)
|
||||
local order = 0
|
||||
|
||||
if id.stale then
|
||||
order += 1
|
||||
note(
|
||||
"⚠ This object carries a leftover runtime bind marker (copied out of a Play session)."
|
||||
.. " Applying or clearing below removes it.",
|
||||
order,
|
||||
Theme.COLOR.DANGER
|
||||
)
|
||||
end
|
||||
|
||||
if id.schema and not chooserOpen then
|
||||
local display = BuildAdmin.displayOf(id.schema)
|
||||
|
||||
-- Identity card + change/clear.
|
||||
order += 1
|
||||
local card = Theme.panel()
|
||||
card.LayoutOrder = order
|
||||
card.Parent = scroll
|
||||
Theme.label({
|
||||
Size = UDim2.new(1, 0, 0, 20),
|
||||
Text = `This is a {display.title}`,
|
||||
Font = Theme.FONT_BOLD,
|
||||
TextSize = 14,
|
||||
LayoutOrder = 0,
|
||||
}).Parent =
|
||||
card
|
||||
Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = if id.multi
|
||||
then `⚠ It also carries another component tag — v1 expects one. {display.summary}`
|
||||
else display.summary,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 12,
|
||||
LayoutOrder = 1,
|
||||
}).Parent =
|
||||
card
|
||||
|
||||
local row = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 26),
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
}) :: Frame
|
||||
row.Parent = card
|
||||
local change = Theme.button({
|
||||
Size = UDim2.new(0.5, -4, 1, 0),
|
||||
Text = "Change type",
|
||||
TextSize = 13,
|
||||
})
|
||||
change.Parent = row
|
||||
change.MouseButton1Click:Connect(function()
|
||||
chooserOpen = true
|
||||
refresh()
|
||||
end)
|
||||
local clearBtn = Theme.button({
|
||||
Size = UDim2.new(0.5, -4, 1, 0),
|
||||
Position = UDim2.new(0.5, 4, 0, 0),
|
||||
Text = "Clear",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
TextSize = 13,
|
||||
})
|
||||
clearBtn.Parent = row
|
||||
clearBtn.MouseButton1Click:Connect(function()
|
||||
clearType({ instance }, all)
|
||||
end)
|
||||
|
||||
if display.hint and display.hint ~= "" then
|
||||
order += 1
|
||||
note(display.hint, order)
|
||||
end
|
||||
|
||||
order = buildForm(instance, id.schema, order)
|
||||
say(`{BuildAdmin.countInPlace(id.schema)} {display.title} in this place`)
|
||||
return
|
||||
end
|
||||
|
||||
-- Untagged (or "Change type"): the chooser.
|
||||
if #id.unknownTags > 0 and not id.schema then
|
||||
order += 1
|
||||
note(
|
||||
`Tagged “{table.concat(id.unknownTags, ", ")}” — not a component this engine version knows.`,
|
||||
order
|
||||
)
|
||||
order += 1
|
||||
local clearBtn = Theme.button({
|
||||
Size = UDim2.new(1, 0, 0, 26),
|
||||
Text = "Clear",
|
||||
TextColor3 = Theme.COLOR.DANGER,
|
||||
TextSize = 13,
|
||||
LayoutOrder = order,
|
||||
})
|
||||
clearBtn.Parent = scroll
|
||||
clearBtn.MouseButton1Click:Connect(function()
|
||||
-- Pass the unknown tags we just listed, so Clear removes exactly what was shown.
|
||||
clearType({ instance }, all, id.unknownTags)
|
||||
end)
|
||||
end
|
||||
|
||||
order += 1
|
||||
note(if chooserOpen then "Change this object to…" else "What is this object?", order, Theme.COLOR.TEXT)
|
||||
|
||||
for _, schema in all do
|
||||
order += 1
|
||||
local eligible, reason = BuildAdmin.isEligible(instance, schema)
|
||||
local card = chooserCard(schema, BuildAdmin.displayOf(schema), eligible, reason, function(picked)
|
||||
applyType({ instance }, picked, all)
|
||||
end)
|
||||
card.LayoutOrder = order
|
||||
card.Parent = scroll
|
||||
end
|
||||
|
||||
if chooserOpen then
|
||||
order += 1
|
||||
local cancel = Theme.button({
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
Text = "Cancel",
|
||||
TextSize = 12,
|
||||
LayoutOrder = order,
|
||||
})
|
||||
cancel.Parent = scroll
|
||||
cancel.MouseButton1Click:Connect(function()
|
||||
chooserOpen = false
|
||||
refresh()
|
||||
end)
|
||||
end
|
||||
say(`Selected: {instance.Name}`)
|
||||
end
|
||||
|
||||
-- Re-render on selection change. Two guards: the window is rebuilt when the engine appears
|
||||
-- (so a stale connection must not touch a destroyed frame), and a refresh while the user is
|
||||
-- typing in one of our boxes would eat the value.
|
||||
local selfDirty = false
|
||||
local connection
|
||||
connection = Selection.SelectionChanged:Connect(function()
|
||||
if not container.Parent then
|
||||
connection:Disconnect()
|
||||
return
|
||||
end
|
||||
local focused = UserInputService:GetFocusedTextBox()
|
||||
if focused and focused:IsDescendantOf(container) then
|
||||
return
|
||||
end
|
||||
chooserOpen = false
|
||||
if container.Visible then
|
||||
refresh()
|
||||
else
|
||||
selfDirty = true
|
||||
end
|
||||
end)
|
||||
container:GetPropertyChangedSignal("Visible"):Connect(function()
|
||||
if container.Visible and selfDirty then
|
||||
selfDirty = false
|
||||
refresh()
|
||||
end
|
||||
end)
|
||||
|
||||
header.refreshBtn.MouseButton1Click:Connect(refresh)
|
||||
refresh()
|
||||
return { refresh = refresh }
|
||||
end
|
||||
|
||||
return BuildAdminUi
|
||||
+5
-102
@@ -19,15 +19,12 @@
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Field = require(script.Parent.Field)
|
||||
|
||||
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?
|
||||
@@ -126,113 +123,19 @@ end
|
||||
-- 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
|
||||
return Field.coerce(field, raw)
|
||||
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
|
||||
return Field.equalsDefault(field, 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)
|
||||
return Field.format(field, value)
|
||||
end
|
||||
|
||||
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Field — pure value plumbing shared by every schema-driven editor in the plugin (Engine Config
|
||||
and Build). No UI, no `plugin` global, no Instances: given a field spec ({ kind, min, max,
|
||||
integer, choices, check }) it coerces a raw input, formats a value for display, and decides
|
||||
whether a value equals its default.
|
||||
|
||||
`kind` is one of "number" | "boolean" | "string" | "enum" | "color3" | "font". An UNKNOWN kind
|
||||
falls through to string, so a newer engine's schema can add kinds without breaking an older
|
||||
plugin.
|
||||
]]
|
||||
|
||||
local Field = {}
|
||||
|
||||
local FLOAT_EPSILON = 1e-4
|
||||
|
||||
-- Coerce a raw input (usually a text box's string) to the field's type.
|
||||
-- Returns { ok = true, value } | { ok = false, error }.
|
||||
function Field.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", and any kind this plugin version doesn't know
|
||||
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
|
||||
|
||||
-- A value as the creator should see it in a text box / button.
|
||||
function Field.format(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
|
||||
|
||||
-- Deltas-only editors store a value ONLY when it differs from the default, so this decides whether
|
||||
-- a write becomes a remove. Numbers compare with a RELATIVE epsilon: an absolute one would swallow
|
||||
-- genuine overrides of very small values.
|
||||
function Field.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
|
||||
|
||||
return Field
|
||||
@@ -0,0 +1,162 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
FieldRow — the shared schema-field row: [○/●] label … [control], plus an optional wrapped help
|
||||
line underneath. The visual language is the Engine Config editor's (a filled dot means "you set
|
||||
this"; an empty dot means "following the default"), so every schema-driven editor in the plugin
|
||||
looks and behaves the same.
|
||||
|
||||
Controls by kind: boolean/enum cycle on click; everything else is a TextBox committed on focus
|
||||
loss, showing the default as its placeholder. When a field declares `ref` (a content category),
|
||||
the box gains a ⌄ button that cycles the ids authored in that category — so a creator picks
|
||||
"oak_tree" instead of remembering it.
|
||||
]]
|
||||
|
||||
local Theme = require(script.Parent.Theme)
|
||||
local Field = require(script.Parent.Field)
|
||||
|
||||
local FieldRow = {}
|
||||
|
||||
local ROW_H = Theme.ROW_H
|
||||
|
||||
-- opts = {
|
||||
-- spec, -- AttributeSpec
|
||||
-- state, -- { value, default, hasOverride, typeMismatch? }
|
||||
-- onCommit(raw) -> (), -- commit an edit (blank = reset)
|
||||
-- onReset() -> (), -- the ○/● dot
|
||||
-- choices: { string }?, -- resolved `ref` ids, for the ⌄ picker
|
||||
-- }
|
||||
function FieldRow.build(opts: any): Frame
|
||||
local spec, state = opts.spec, opts.state
|
||||
local hasHelp = typeof(spec.help) == "string" and spec.help ~= ""
|
||||
|
||||
local row = Theme.make("Frame", {
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Theme.make("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 1),
|
||||
}),
|
||||
}) :: Frame
|
||||
|
||||
local top = Theme.make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, ROW_H),
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 1,
|
||||
}) :: Frame
|
||||
top.Parent = row
|
||||
|
||||
-- Override dot: filled + accented when this instance sets the attribute.
|
||||
local dot = Theme.button({
|
||||
Size = UDim2.fromOffset(18, 18),
|
||||
Position = UDim2.fromOffset(0, 4),
|
||||
Text = if state.hasOverride then "●" else "○",
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.ACCENT else Theme.COLOR.DIM,
|
||||
BackgroundTransparency = 1,
|
||||
TextSize = 13,
|
||||
})
|
||||
dot.Parent = top
|
||||
dot.MouseButton1Click:Connect(function()
|
||||
opts.onReset()
|
||||
end)
|
||||
|
||||
local warn = if state.typeMismatch then " ⚠" else ""
|
||||
Theme.label({
|
||||
Size = UDim2.new(0.42, -22, 1, 0),
|
||||
Position = UDim2.fromOffset(22, 0),
|
||||
Text = spec.label .. warn,
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}).Parent =
|
||||
top
|
||||
|
||||
local controlX = UDim2.new(0.42, 4, 0, 3)
|
||||
local controlW = UDim2.new(0.58, -4, 0, ROW_H - 6)
|
||||
|
||||
if spec.kind == "boolean" or spec.kind == "enum" then
|
||||
local current = Field.format(spec, state.value)
|
||||
local control = Theme.button({
|
||||
Size = controlW,
|
||||
Position = controlX,
|
||||
Text = if current == "" then "(blank)" else current,
|
||||
TextColor3 = if state.hasOverride then Theme.COLOR.TEXT else Theme.COLOR.DIM,
|
||||
TextSize = 13,
|
||||
})
|
||||
control.Parent = top
|
||||
control.MouseButton1Click:Connect(function()
|
||||
if spec.kind == "boolean" then
|
||||
opts.onCommit(not (state.value == true))
|
||||
return
|
||||
end
|
||||
local choices = spec.choices or {}
|
||||
local index = 0
|
||||
for i, choice in choices do
|
||||
if choice == current then
|
||||
index = i
|
||||
break
|
||||
end
|
||||
end
|
||||
local nextChoice = choices[(index % math.max(1, #choices)) + 1]
|
||||
if nextChoice ~= nil then
|
||||
opts.onCommit(nextChoice)
|
||||
end
|
||||
end)
|
||||
else
|
||||
local pickable = opts.choices and #opts.choices > 0
|
||||
local box = Theme.textBox({
|
||||
Size = if pickable then UDim2.new(0.58, -28, 0, ROW_H - 6) else controlW,
|
||||
Position = controlX,
|
||||
Text = if state.hasOverride then Field.format(spec, state.value) else "",
|
||||
PlaceholderText = spec.placeholder or Field.format(spec, spec.default),
|
||||
})
|
||||
box.Parent = top
|
||||
box.FocusLost:Connect(function()
|
||||
opts.onCommit(box.Text)
|
||||
end)
|
||||
|
||||
if pickable then
|
||||
-- Cycle the ids authored in the referenced content category.
|
||||
local pick = Theme.button({
|
||||
Size = UDim2.fromOffset(22, ROW_H - 6),
|
||||
Position = UDim2.new(1, -22, 0, 3),
|
||||
Text = "⌄",
|
||||
TextSize = 13,
|
||||
})
|
||||
pick.Parent = top
|
||||
pick.MouseButton1Click:Connect(function()
|
||||
local choices = opts.choices
|
||||
local current = if state.hasOverride then Field.format(spec, state.value) else ""
|
||||
local index = 0
|
||||
for i, choice in choices do
|
||||
if choice == current then
|
||||
index = i
|
||||
break
|
||||
end
|
||||
end
|
||||
opts.onCommit(choices[(index % #choices) + 1])
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
if hasHelp then
|
||||
-- `row` owns a UIListLayout, which overwrites child Position — so the indent under the
|
||||
-- label column has to come from padding, not Position.
|
||||
local help = Theme.label({
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = spec.help,
|
||||
TextColor3 = Theme.COLOR.DIM,
|
||||
TextWrapped = true,
|
||||
TextSize = 11,
|
||||
TextTruncate = Enum.TextTruncate.None,
|
||||
LayoutOrder = 2,
|
||||
})
|
||||
Theme.pad(22, 0, 0, 2).Parent = help
|
||||
help.Parent = row
|
||||
end
|
||||
|
||||
return row
|
||||
end
|
||||
|
||||
return FieldRow
|
||||
@@ -171,6 +171,27 @@ function Theme.panel(): Frame
|
||||
}) :: Frame
|
||||
end
|
||||
|
||||
-- A CLICKABLE panel: identical styling to Theme.panel, but the card itself is the button.
|
||||
-- Use this instead of parenting a full-size button "overlay" into a panel — the panel's own
|
||||
-- UIListLayout lays out EVERY GuiObject child (there is no opt-out, and ZIndex doesn't change
|
||||
-- layout), so such an "overlay" becomes another list row: the card body stops being clickable and
|
||||
-- the oversized button spills onto whatever is rendered next.
|
||||
function Theme.panelButton(): TextButton
|
||||
return Theme.make("TextButton", {
|
||||
BackgroundColor3 = Theme.COLOR.PANEL,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.fromScale(1, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.Y,
|
||||
Text = "",
|
||||
AutoButtonColor = false, -- Theme.hover owns the highlight, matching the other cards
|
||||
}, {
|
||||
Theme.corner(8),
|
||||
Theme.stroke(),
|
||||
Theme.pad(8, 8, 6, 8),
|
||||
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
||||
}) :: TextButton
|
||||
end
|
||||
|
||||
-- A small count/status pill (rail badges, entry badges).
|
||||
function Theme.badge(text: string, textColor: Color3?): TextLabel
|
||||
return Theme.make("TextLabel", {
|
||||
|
||||
@@ -32,6 +32,8 @@ local ContentAdmin = require(script.ContentAdmin)
|
||||
local ContentAdminUi = require(script.ContentAdminUi)
|
||||
local ConfigAdmin = require(script.ConfigAdmin)
|
||||
local ConfigAdminUi = require(script.ConfigAdminUi)
|
||||
local BuildAdmin = require(script.BuildAdmin)
|
||||
local BuildAdminUi = require(script.BuildAdminUi)
|
||||
|
||||
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
||||
|
||||
@@ -112,6 +114,39 @@ local function applyConfigResetSection(section: any): any
|
||||
end)
|
||||
end
|
||||
|
||||
-- ── Build callbacks (world objects: tag + attributes) ────────────────────────
|
||||
-- One ChangeHistory recording per call, kept strictly at this boundary: BuildAdmin functions must
|
||||
-- never call each other through here, since a nested TryBeginRecording returns nil.
|
||||
local buildActions = {
|
||||
applyType = function(instances: { Instance }, schema: any, all: { any }): any
|
||||
return record(`Build: {BuildAdmin.displayOf(schema).title}`, function()
|
||||
return BuildAdmin.applyType(instances, schema, all)
|
||||
end)
|
||||
end,
|
||||
clear = function(instances: { Instance }, all: { any }, extraTags: { string }?): any
|
||||
return record("Build: clear component", function()
|
||||
return BuildAdmin.clear(instances, all, extraTags)
|
||||
end)
|
||||
end,
|
||||
setField = function(instance: Instance, spec: any, raw: any): any
|
||||
return record(`Build: {spec.attr}`, function()
|
||||
return BuildAdmin.setField(instance, spec, raw)
|
||||
end)
|
||||
end,
|
||||
resetField = function(instance: Instance, spec: any): any
|
||||
return record(`Build: reset {spec.attr}`, function()
|
||||
return BuildAdmin.resetField(instance, spec)
|
||||
end)
|
||||
end,
|
||||
-- Not a mutation (no recording): jump to a Content page so a field that references authored
|
||||
-- content can send a first-time creator somewhere useful instead of dead-ending.
|
||||
selectPage = function(id: string)
|
||||
if nav then
|
||||
nav.selectPage(id)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
-- ── Content callbacks (authored + overrides), bundled for the pages ──────────
|
||||
local actions = {
|
||||
set = function(catKey: string, id: string, field: any, raw: any): any
|
||||
@@ -185,6 +220,17 @@ local function mountUi()
|
||||
end,
|
||||
})
|
||||
|
||||
-- Build: select a world object → "what is this?" → a schema-rendered setup form. Registered
|
||||
-- unconditionally; it renders its own explainer when the engine (or its schemas) is missing.
|
||||
table.insert(pages, {
|
||||
id = "build",
|
||||
label = "Build",
|
||||
section = "",
|
||||
mount = function(frame)
|
||||
return BuildAdminUi.mount(frame, BuildAdmin, ContentAdmin, buildActions)
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(pages, {
|
||||
id = "stats",
|
||||
label = "Survival Stats",
|
||||
|
||||
+5
-3
@@ -55,7 +55,7 @@
|
||||
<div class="hero-inner">
|
||||
<img class="hero-logo" src="assets/img/logo-256.png" alt="SurvivorCore logo" width="112" height="112" />
|
||||
<a class="badge" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">
|
||||
v0.9.0 · pre-release
|
||||
v0.10.0 · pre-release
|
||||
</a>
|
||||
<h1>The survival game engine for <span class="accent">Roblox</span></h1>
|
||||
<p class="lede">
|
||||
@@ -152,7 +152,8 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6 6 2 2 6-6a4 4 0 0 0 5.4-5.4l-2.3 2.3-2-2 2.3-2.3z"/></svg>
|
||||
</span>
|
||||
<h3>SurvivorCore Studio — no-code admin</h3>
|
||||
<p>One floating window with a sidebar & search: tune <strong>every engine config section</strong> — movement, combat, mobs, loot, even UI theme colours & fonts — as locked deltas that survive engine updates. Create items, weapons, ammo, mobs, quests and achievements from forms, <strong>override code-registered content</strong> field-by-field, and drop any of it into the world. No scripting.</p>
|
||||
<p>One floating window with a sidebar & search: tune <strong>every engine config section</strong> — movement, combat, mobs, loot, even UI theme colours & fonts — as locked deltas that survive engine updates. Create items, weapons, ammo, mobs, quests and achievements from forms, and <strong>override code-registered content</strong> field-by-field. No scripting.</p>
|
||||
<p>Then <strong>Build</strong> turns your own geometry into game content: select any Part or Model, answer <em>“what is this object?”</em>, and fill a form — it becomes a gatherable node, a creature or a quest giver. The form is generated from the engine's own component schema, so <strong>every component gets a setup form for free</strong>, with no UI code behind it.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -180,6 +181,7 @@
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/oVJwYUhCKxA2ZvgvocdhwZ" title="Hunting & loot bags demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Hunting & loot bags</span></div>
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/g4oySJeXD4Th7f1zYEu9Bz" title="SurvivorCore Studio admin panel demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>SurvivorCore Studio</span></div>
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/sJmS6L15jRmwxhQCE4Zgmi" title="Player trading demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Player trading</span></div>
|
||||
<div class="video"><iframe src="https://makertube.net/videos/embed/2kkyPbDWqoyuKcgbiCGwQG" title="Build — no-code world objects demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Build: no-code objects</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -195,7 +197,7 @@
|
||||
<p>Grab the drop-in model from the latest release, or add it with Wally:</p>
|
||||
<pre><code># wally.toml
|
||||
[dependencies]
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"</code></pre>
|
||||
SurvivorCore = "temujincalidius/survivorcore@0.10.0"</code></pre>
|
||||
<p class="muted">…or drop <code>SurvivorCore.rbxm</code> into <code>ReplicatedStorage</code>.</p>
|
||||
<p>
|
||||
<a class="btn btn-ghost" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">Latest release ↗</a>
|
||||
|
||||
@@ -1 +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"]}]}
|
||||
{"name":"SurvivorCoreStatAdmin","className":"Script","filePaths":["plugin/init.server.luau","plugin.project.json"],"children":[{"name":"BuildAdmin","className":"ModuleScript","filePaths":["plugin/BuildAdmin.luau"]},{"name":"BuildAdminUi","className":"ModuleScript","filePaths":["plugin/BuildAdminUi.luau"]},{"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":"Field","className":"ModuleScript","filePaths":["plugin/Field.luau"]},{"name":"FieldRow","className":"ModuleScript","filePaths":["plugin/FieldRow.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"]}]}
|
||||
@@ -15,6 +15,7 @@
|
||||
]]
|
||||
|
||||
local Components = require(script.Parent)
|
||||
local Schema = require(script.Parent.Schema)
|
||||
local Registries = require(script.Parent.Parent.registries)
|
||||
local Harvesting = require(script.Parent.Parent.systems.Harvesting)
|
||||
|
||||
@@ -64,22 +65,15 @@ local function resolve(values: { [string]: any })
|
||||
}
|
||||
end
|
||||
|
||||
-- Attributes, their defaults and their creator-facing help text live in the schema, so the Studio
|
||||
-- plugin renders this component's setup form from the same source the engine binds from.
|
||||
local SCHEMA = Schema.COMPONENTS.Gatherable
|
||||
|
||||
return Components.define({
|
||||
name = "Gatherable",
|
||||
tag = "Gatherable",
|
||||
attributes = {
|
||||
Resource = "", -- named resource def to inherit from (optional)
|
||||
ItemId = "", -- override / bare-hand item id
|
||||
Yield = 0, -- legacy single per-hit yield (use YieldMin/Max for a range)
|
||||
YieldMin = 0,
|
||||
YieldMax = 0,
|
||||
HP = 0, -- 0 = inherit from the resource def (or default 3)
|
||||
RequireTool = "", -- tool type needed (e.g. "axe"); "" = bare-hand
|
||||
Interaction = "auto", -- "auto" | "prompt" | "tool"
|
||||
DestroyOnDeplete = true, -- false keeps the node for a reaction to transform (stump/fell)
|
||||
PromptText = "", -- prompt-mode ActionText override ("Butcher", "Pick"); "" = "Gather"
|
||||
PromptObject = "", -- prompt-mode ObjectText override; "" = resource / item id
|
||||
},
|
||||
name = SCHEMA.name,
|
||||
tag = SCHEMA.tag,
|
||||
display = SCHEMA.display,
|
||||
attributes = SCHEMA.attributes,
|
||||
onSetup = function(instance, values)
|
||||
local r = resolve(values)
|
||||
|
||||
|
||||
+9
-15
@@ -12,24 +12,18 @@
|
||||
]]
|
||||
|
||||
local Components = require(script.Parent)
|
||||
local Schema = require(script.Parent.Schema)
|
||||
local Mobs = require(script.Parent.Parent.systems.Mobs)
|
||||
|
||||
-- Attributes + their creator-facing help text live in the schema (the Studio plugin renders this
|
||||
-- component's setup form from it).
|
||||
local SCHEMA = Schema.COMPONENTS.Mob
|
||||
|
||||
return Components.define({
|
||||
name = "Mob",
|
||||
tag = "Mob",
|
||||
attributes = {
|
||||
MobType = "", -- the Mobs def id to inherit from; blank = use the model's Name
|
||||
Faction = "", -- "hostile" | "passive" | "neutral"; blank = inherit the def (default "neutral")
|
||||
Health = 0, -- 0 = inherit the def (or 50)
|
||||
WalkSpeed = 0, -- 0 = inherit
|
||||
RunSpeed = 0, -- 0 = inherit
|
||||
AggroRange = 0, -- 0 = inherit / Config default
|
||||
LeashRange = 0, -- 0 = inherit / Config default
|
||||
AttackRange = 0, -- 0 = inherit / Config default
|
||||
AttackDamage = 0, -- 0 = inherit / Config default
|
||||
AttackCooldown = 0, -- 0 = inherit / Config default
|
||||
WanderRadius = 0, -- 0 = inherit / Config default
|
||||
},
|
||||
name = SCHEMA.name,
|
||||
tag = SCHEMA.tag,
|
||||
display = SCHEMA.display,
|
||||
attributes = SCHEMA.attributes,
|
||||
onSetup = function(instance, _values)
|
||||
-- The server Mobs runtime reads the instance attributes directly (one merge path:
|
||||
-- override attr › registry def › "Mobs" Config), so nothing to stash here — just adopt it.
|
||||
|
||||
@@ -13,16 +13,20 @@
|
||||
]]
|
||||
|
||||
local Components = require(script.Parent)
|
||||
local Schema = require(script.Parent.Schema)
|
||||
local Registries = require(script.Parent.Parent.registries)
|
||||
local Quests = require(script.Parent.Parent.systems.Quests)
|
||||
local QuestData = require(script.Parent.Parent.shared.QuestData)
|
||||
|
||||
-- Attributes + their creator-facing help text live in the schema (the Studio plugin renders this
|
||||
-- component's setup form from it).
|
||||
local SCHEMA = Schema.COMPONENTS.QuestGiver
|
||||
|
||||
return Components.define({
|
||||
name = "QuestGiver",
|
||||
tag = "QuestGiver",
|
||||
attributes = {
|
||||
Quest = "", -- the quest id this giver offers / accepts turn-ins for
|
||||
},
|
||||
name = SCHEMA.name,
|
||||
tag = SCHEMA.tag,
|
||||
display = SCHEMA.display,
|
||||
attributes = SCHEMA.attributes,
|
||||
onSetup = function(instance, values)
|
||||
local questId = tostring(values.Quest or "")
|
||||
if questId == "" then
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
--!nonstrict
|
||||
--[[
|
||||
Component schemas — the PURE description of every creator component: what attributes it reads,
|
||||
their kind/default/help/options, and how a builder UI should present it.
|
||||
|
||||
This module has NO dependencies, on purpose. The Studio plugin requires it LIVE at edit time
|
||||
(ReplicatedStorage.SurvivorCore.components.Schema), exactly as the plugin's Engine Config editor
|
||||
requires shared/EngineConfig — so the plugin and the engine can never disagree about a
|
||||
component's fields. Requiring a component module instead is NOT an option: those pull in server
|
||||
systems (Harvesting asserts IsServer at module scope; Remotes CREATES instances in
|
||||
ReplicatedStorage), which would throw or pollute the creator's place file in Edit mode.
|
||||
|
||||
Attribute names here are PascalCase INSTANCE attributes (`ItemId`, `HP`) — a different namespace
|
||||
from the lowercase registry-def fields the Content editor writes (`item`, `hp`). Never mix them:
|
||||
a def says what "oak_tree" IS; an instance attribute says what THIS part is (or which def it
|
||||
inherits from).
|
||||
|
||||
Attributes beginning with "_" are ENGINE-INTERNAL (the `_scBound` bind marker, Gatherable's
|
||||
resolved `_ItemId`/`_HP`/… stamps). They are never authored, never shown, and are cleared when
|
||||
a builder untags an instance.
|
||||
]]
|
||||
|
||||
local Schema = {}
|
||||
|
||||
-- Bumped when the shape changes. Readers must tolerate unknown fields and unknown `kind`s.
|
||||
Schema.VERSION = 1
|
||||
|
||||
export type AttributeSpec = {
|
||||
attr: string, -- PascalCase instance Attribute name
|
||||
kind: string, -- "number" | "boolean" | "string" | "enum" (unknown kinds render as text)
|
||||
label: string,
|
||||
default: any, -- MUST equal the value the component's onSetup assumes
|
||||
help: string?, -- one sentence shown under the field
|
||||
choices: { string }?, -- kind == "enum"
|
||||
min: number?,
|
||||
max: number?,
|
||||
integer: boolean?,
|
||||
placeholder: string?,
|
||||
group: string?, -- form section header; first-appearance order wins. nil = "Settings"
|
||||
ref: string?, -- names a content category ("Items"/"Resources"/"Mobs"/"Quests") whose authored
|
||||
-- ids a builder can offer as a picker. A UI hint only — the engine ignores it.
|
||||
advanced: boolean?, -- hidden behind "show advanced" (legacy / rarely-set fields)
|
||||
}
|
||||
|
||||
export type Display = {
|
||||
title: string, -- chooser card title
|
||||
summary: string, -- one line: what this component makes the object do
|
||||
instance: string?, -- "BasePart" | "Model" | "any" (default "any") — chooser eligibility
|
||||
icon: string?, -- reserved (rbxassetid://…)
|
||||
order: number?, -- chooser sort; ties break on title
|
||||
hint: string?, -- shown above the form
|
||||
}
|
||||
|
||||
export type ComponentSchema = {
|
||||
name: string,
|
||||
tag: string,
|
||||
display: Display,
|
||||
attributes: { AttributeSpec },
|
||||
}
|
||||
|
||||
-- ── Normalisation (accepts the legacy shorthand as well as the schema form) ───
|
||||
|
||||
-- "ItemId" -> "Item Id". Only used for LEGACY map-form attributes, which declare no label.
|
||||
local function humanize(attr: string): string
|
||||
return (string.gsub(attr, "(%l)(%u)", "%1 %2"))
|
||||
end
|
||||
|
||||
local function inferKind(default: any): string
|
||||
local t = typeof(default)
|
||||
return if t == "number" then "number" elseif t == "boolean" then "boolean" else "string"
|
||||
end
|
||||
|
||||
-- Always returns an ORDERED array of AttributeSpec, from either form:
|
||||
-- schema: { { attr = "HP", kind = "number", … }, … } (array — attributes[1] ~= nil)
|
||||
-- legacy: { HP = 0, ItemId = "" } (map — sorted, since `pairs` order is
|
||||
-- arbitrary and would make a UI jitter)
|
||||
function Schema.normalize(attributes: any?): { AttributeSpec }
|
||||
if attributes == nil then
|
||||
return {}
|
||||
end
|
||||
if (attributes :: any)[1] ~= nil then
|
||||
for _, spec in attributes :: { AttributeSpec } do
|
||||
assert(typeof(spec.attr) == "string", "component attribute spec needs `attr`")
|
||||
end
|
||||
return attributes :: { AttributeSpec }
|
||||
end
|
||||
local out: { AttributeSpec } = {}
|
||||
for attr, default in attributes :: { [string]: any } do
|
||||
table.insert(out, { attr = attr, kind = inferKind(default), label = humanize(attr), default = default })
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
return a.attr < b.attr
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
-- attr -> default map: exactly what the legacy `attributes` table was, so binding is unchanged.
|
||||
function Schema.defaults(attributes: { AttributeSpec }): { [string]: any }
|
||||
local out = {}
|
||||
for _, spec in attributes do
|
||||
out[spec.attr] = spec.default
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ── The shipped component schemas ────────────────────────────────────────────
|
||||
-- Defaults are copied VERBATIM from each component's original attribute table: they are behaviour,
|
||||
-- not decoration (Gatherable's resolve() reads "" / 0 as "inherit from the resource def").
|
||||
|
||||
local INHERIT_ZERO = "0 = inherit the mob def, then the Mobs config."
|
||||
|
||||
Schema.COMPONENTS = {
|
||||
Gatherable = {
|
||||
name = "Gatherable",
|
||||
tag = "Gatherable",
|
||||
display = {
|
||||
title = "Gatherable",
|
||||
summary = "A tree, rock or bush players harvest for items.",
|
||||
instance = "any",
|
||||
order = 1,
|
||||
hint = "Point it at a Gatherables entry (easiest) — or leave that blank and set the item,"
|
||||
.. " HP and yield by hand. Blank / 0 fields follow the entry.",
|
||||
},
|
||||
attributes = {
|
||||
{
|
||||
attr = "Resource",
|
||||
kind = "string",
|
||||
label = "Resource",
|
||||
default = "",
|
||||
ref = "Resources",
|
||||
placeholder = "a Gatherables id",
|
||||
group = "What it is",
|
||||
help = "Inherit item, HP, tool and yield from a Content › Gatherables entry.",
|
||||
},
|
||||
{
|
||||
attr = "ItemId",
|
||||
kind = "string",
|
||||
label = "Item yielded",
|
||||
default = "",
|
||||
ref = "Items",
|
||||
group = "What it is",
|
||||
help = "Overrides the resource's item. Blank = use the resource.",
|
||||
},
|
||||
{
|
||||
attr = "HP",
|
||||
kind = "number",
|
||||
label = "HP (hits to deplete)",
|
||||
default = 0,
|
||||
min = 0,
|
||||
integer = true,
|
||||
group = "Harvesting",
|
||||
help = "0 = inherit the resource (or 3).",
|
||||
},
|
||||
{
|
||||
attr = "RequireTool",
|
||||
kind = "string",
|
||||
label = "Tool required",
|
||||
default = "",
|
||||
placeholder = "axe / pickaxe",
|
||||
group = "Harvesting",
|
||||
help = "Blank = bare hands (hold-E). Set a tool type to make it click-to-swing.",
|
||||
},
|
||||
{
|
||||
attr = "YieldMin",
|
||||
kind = "number",
|
||||
label = "Yield min",
|
||||
default = 0,
|
||||
min = 0,
|
||||
integer = true,
|
||||
group = "Harvesting",
|
||||
help = "Items per hit, low end. 0 = inherit the resource.",
|
||||
},
|
||||
{
|
||||
attr = "YieldMax",
|
||||
kind = "number",
|
||||
label = "Yield max",
|
||||
default = 0,
|
||||
min = 0,
|
||||
integer = true,
|
||||
group = "Harvesting",
|
||||
help = "Items per hit, high end. 0 = inherit the resource.",
|
||||
},
|
||||
{
|
||||
attr = "Interaction",
|
||||
kind = "enum",
|
||||
label = "Interaction",
|
||||
default = "auto",
|
||||
choices = { "auto", "prompt", "tool" },
|
||||
group = "Harvesting",
|
||||
help = "auto = tool if a tool is required, else a hold-E prompt.",
|
||||
},
|
||||
{
|
||||
attr = "DestroyOnDeplete",
|
||||
kind = "boolean",
|
||||
label = "Destroy when depleted",
|
||||
default = true,
|
||||
group = "Harvesting",
|
||||
help = "Off keeps the node so a reaction can turn it into a stump.",
|
||||
},
|
||||
{
|
||||
attr = "PromptText",
|
||||
kind = "string",
|
||||
label = "Prompt action text",
|
||||
default = "",
|
||||
placeholder = "Gather",
|
||||
group = "Prompt (hold-E only)",
|
||||
},
|
||||
{
|
||||
attr = "PromptObject",
|
||||
kind = "string",
|
||||
label = "Prompt object text",
|
||||
default = "",
|
||||
placeholder = "the resource / item id",
|
||||
group = "Prompt (hold-E only)",
|
||||
},
|
||||
{
|
||||
attr = "Yield",
|
||||
kind = "number",
|
||||
label = "Yield (legacy)",
|
||||
default = 0,
|
||||
min = 0,
|
||||
integer = true,
|
||||
advanced = true,
|
||||
group = "Harvesting",
|
||||
help = "Legacy single per-hit yield. Prefer Yield min / max.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Mob = {
|
||||
name = "Mob",
|
||||
tag = "Mob",
|
||||
display = {
|
||||
title = "Mob (creature)",
|
||||
summary = "A creature with AI: wanders, chases, attacks or flees.",
|
||||
instance = "Model",
|
||||
order = 2,
|
||||
hint = "Needs a Model with a Humanoid and a PrimaryPart. Point it at a Mobs entry;"
|
||||
.. " every override below is optional.",
|
||||
},
|
||||
attributes = {
|
||||
{
|
||||
attr = "MobType",
|
||||
kind = "string",
|
||||
label = "Mob type",
|
||||
default = "",
|
||||
ref = "Mobs",
|
||||
placeholder = "a Mobs id (blank = the model's name)",
|
||||
group = "What it is",
|
||||
help = "The Content › Mobs entry to inherit from.",
|
||||
},
|
||||
{
|
||||
attr = "Faction",
|
||||
kind = "enum",
|
||||
label = "Faction",
|
||||
default = "",
|
||||
choices = { "", "hostile", "passive", "neutral" },
|
||||
group = "What it is",
|
||||
help = "Picks the AI profile. Blank = inherit the def (then neutral).",
|
||||
},
|
||||
{
|
||||
attr = "Health",
|
||||
kind = "number",
|
||||
label = "Health",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = "0 = inherit the mob def (or 50).",
|
||||
},
|
||||
{
|
||||
attr = "WalkSpeed",
|
||||
kind = "number",
|
||||
label = "Walk speed",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "RunSpeed",
|
||||
kind = "number",
|
||||
label = "Run speed",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "AggroRange",
|
||||
kind = "number",
|
||||
label = "Aggro range",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "LeashRange",
|
||||
kind = "number",
|
||||
label = "Leash range",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "AttackRange",
|
||||
kind = "number",
|
||||
label = "Attack range",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "AttackDamage",
|
||||
kind = "number",
|
||||
label = "Attack damage",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "AttackCooldown",
|
||||
kind = "number",
|
||||
label = "Attack cooldown (s)",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
{
|
||||
attr = "WanderRadius",
|
||||
kind = "number",
|
||||
label = "Wander radius",
|
||||
default = 0,
|
||||
min = 0,
|
||||
group = "Overrides (0 = inherit)",
|
||||
help = INHERIT_ZERO,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
QuestGiver = {
|
||||
name = "QuestGiver",
|
||||
tag = "QuestGiver",
|
||||
display = {
|
||||
title = "Quest giver",
|
||||
summary = "A sign or NPC that hands out one quest and takes its turn-in.",
|
||||
instance = "any",
|
||||
order = 3,
|
||||
hint = "Point it at a quest; the engine attaches the accept / turn-in prompt.",
|
||||
},
|
||||
attributes = {
|
||||
{
|
||||
attr = "Quest",
|
||||
kind = "string",
|
||||
label = "Quest",
|
||||
default = "",
|
||||
ref = "Quests",
|
||||
placeholder = "a Quests id",
|
||||
group = "What it is",
|
||||
help = "The quest this giver offers and accepts turn-ins for.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Chooser order. A reader must tolerate this naming an entry that no longer exists, AND entries
|
||||
-- this list doesn't cover (a newer engine adding a component).
|
||||
Schema.ORDER = { "Gatherable", "Mob", "QuestGiver" }
|
||||
|
||||
function Schema.get(name: string): ComponentSchema?
|
||||
return Schema.COMPONENTS[name]
|
||||
end
|
||||
|
||||
-- Every schema, in ORDER first, then anything ORDER missed (alphabetically) — so a newer engine's
|
||||
-- components still surface in an older reader.
|
||||
function Schema.list(): { ComponentSchema }
|
||||
local out, seen = {}, {}
|
||||
for _, name in Schema.ORDER do
|
||||
local entry = Schema.COMPONENTS[name]
|
||||
if entry then
|
||||
seen[name] = true
|
||||
table.insert(out, entry)
|
||||
end
|
||||
end
|
||||
local rest = {}
|
||||
for name in Schema.COMPONENTS do
|
||||
if not seen[name] then
|
||||
table.insert(rest, name)
|
||||
end
|
||||
end
|
||||
table.sort(rest)
|
||||
for _, name in rest do
|
||||
table.insert(out, Schema.COMPONENTS[name])
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return Schema
|
||||
@@ -11,30 +11,74 @@
|
||||
})
|
||||
|
||||
Call Components.scan() once (SurvivorCore.start does this) to bind existing tagged
|
||||
instances and watch for new ones. A future builder UI can set these attributes
|
||||
visually instead of by hand.
|
||||
instances and watch for new ones.
|
||||
|
||||
`attributes` accepts EITHER form:
|
||||
• the shorthand map above (attribute -> default), or
|
||||
• a SCHEMA array of AttributeSpec (kind/label/help/choices/…) — see components/Schema.luau.
|
||||
Both bind identically; the schema form additionally lets the Studio plugin render a setup form
|
||||
for the component, so a creator picks "what is this object?" instead of typing attribute names.
|
||||
Declare a `display` too ({ title, summary, instance }) so it can appear in that chooser.
|
||||
]]
|
||||
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
|
||||
local Schema = require(script.Schema)
|
||||
|
||||
local Components = {}
|
||||
|
||||
export type AttributeSpec = Schema.AttributeSpec
|
||||
|
||||
export type Spec = {
|
||||
name: string,
|
||||
tag: string,
|
||||
attributes: { [string]: any }?, -- attribute name -> default value
|
||||
-- attribute -> default (shorthand) OR an array of AttributeSpec (schema form)
|
||||
attributes: ({ [string]: any } | { Schema.AttributeSpec })?,
|
||||
display: Schema.Display?, -- builder-UI presentation (title / summary / eligible class / hint)
|
||||
onSetup: ((instance: Instance, values: { [string]: any }) -> ())?,
|
||||
}
|
||||
|
||||
local defined: { [string]: Spec } = {}
|
||||
local schemas: { [string]: Schema.ComponentSchema } = {}
|
||||
local defaults: { [string]: { [string]: any } } = {} -- the attr->default map bind() reads
|
||||
|
||||
function Components.define(spec: Spec): Spec
|
||||
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
|
||||
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
|
||||
local attributes = Schema.normalize(spec.attributes)
|
||||
defined[spec.name] = spec
|
||||
schemas[spec.name] = {
|
||||
name = spec.name,
|
||||
tag = spec.tag,
|
||||
display = spec.display or { title = spec.name, summary = "" },
|
||||
attributes = attributes,
|
||||
}
|
||||
defaults[spec.name] = Schema.defaults(attributes)
|
||||
return spec
|
||||
end
|
||||
|
||||
-- Introspection (the runtime mirror of components/Schema.luau, including anything a game defined
|
||||
-- at runtime — which the edit-time plugin can't see).
|
||||
function Components.getSchema(name: string): Schema.ComponentSchema?
|
||||
return schemas[name]
|
||||
end
|
||||
|
||||
function Components.listSchemas(): { Schema.ComponentSchema }
|
||||
local out = {}
|
||||
for _, entry in schemas do
|
||||
table.insert(out, entry)
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local ao = (a.display and tonumber(a.display.order)) or 100
|
||||
local bo = (b.display and tonumber(b.display.order)) or 100
|
||||
if ao ~= bo then
|
||||
return ao < bo
|
||||
end
|
||||
return a.name < b.name
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function readAttributes(instance: Instance, attributes: { [string]: any }?)
|
||||
local values = {}
|
||||
if attributes then
|
||||
@@ -52,7 +96,8 @@ local function bind(instance: Instance, spec: Spec)
|
||||
end
|
||||
instance:SetAttribute("_scBound", true)
|
||||
if spec.onSetup then
|
||||
spec.onSetup(instance, readAttributes(instance, spec.attributes))
|
||||
-- Always the normalized attr->default map, so both `attributes` forms bind identically.
|
||||
spec.onSetup(instance, readAttributes(instance, defaults[spec.name]))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ local EngineConfig = require(script.shared.EngineConfig)
|
||||
|
||||
local SurvivorCore = {}
|
||||
|
||||
SurvivorCore.VERSION = "0.9.0"
|
||||
SurvivorCore.VERSION = "0.10.0"
|
||||
|
||||
-- Foundation
|
||||
SurvivorCore.Config = Config
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "temujincalidius/survivorcore"
|
||||
description = "Batteries-included, creator-extensible survival game framework for Roblox."
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
license = "MIT"
|
||||
authors = ["Samuel Lison"]
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
|
||||
Reference in New Issue
Block a user