Merge pull request #100 from TemujinCalidius/dev

release: v0.10.0 → main
This commit is contained in:
Samuel Lison
2026-08-01 19:31:14 +10:00
committed by GitHub
26 changed files with 1859 additions and 171 deletions
+27
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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.
+21 -10
View File
@@ -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)
+5 -2
View File
@@ -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
+27
View File
@@ -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,
}
+5 -2
View File
@@ -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
View File
@@ -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
+38
View File
@@ -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
+1 -1
View File
@@ -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:
+310
View File
@@ -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
+520
View File
@@ -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
View File
@@ -19,15 +19,12 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Field = require(script.Parent.Field)
local ConfigAdmin = {}
ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig"
-- Relative float tolerance: |vd| ≤ 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) ──────────────────────────────────
+129
View File
@@ -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
+162
View File
@@ -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
+21
View File
@@ -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", {
+46
View File
@@ -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
View File
@@ -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 &amp; search: tune <strong>every engine config section</strong> — movement, combat, mobs, loot, even UI theme colours &amp; 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 &amp; search: tune <strong>every engine config section</strong> — movement, combat, mobs, loot, even UI theme colours &amp; 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 &amp; loot bags demo" loading="lazy" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-fullscreen"></iframe><span>Hunting &amp; 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
View File
@@ -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"]}]}
+9 -15
View File
@@ -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
View File
@@ -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.
+9 -5
View File
@@ -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
+402
View File
@@ -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
+49 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"