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`
|
[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`.
|
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
|
## 0.9.0 — 2026-07-30
|
||||||
|
|
||||||
### Security
|
### 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`,
|
- **Registries** — developers call `register()` from code (`Items`, `Recipes`, `Stats`,
|
||||||
`Mobs`, …).
|
`Mobs`, …).
|
||||||
- **Components** — creators tag their own objects and set Attributes (`Gatherable`, and the
|
- **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,
|
- **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.
|
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.
|
If you need a new extension point, add a `Hooks.run("…")` call and document it.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
</div>
|
</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
|
> being grown toward v1.0 and APIs may still shift. Production-tested in
|
||||||
> [The Counter Earth](https://thecounterearth.com).
|
> [The Counter Earth](https://thecounterearth.com).
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ into `ReplicatedStorage`, or add it via [Wally](https://wally.run):
|
|||||||
```toml
|
```toml
|
||||||
# wally.toml
|
# wally.toml
|
||||||
[dependencies]
|
[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.
|
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 ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
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 -----------------------------------------------------
|
-- 1. Programmatic content -----------------------------------------------------
|
||||||
-- NOTE: the `icon` ids below are PLACEHOLDERS (the engine's stat icons) so the demo grid
|
-- 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"
|
-- 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_Head", "straw_hat") -- a pre-filled equipment slot
|
||||||
player:SetAttribute("EquipSlot_Back", "reed_satchel") -- equipped for +slots / +carry weight
|
player:SetAttribute("EquipSlot_Back", "reed_satchel") -- equipped for +slots / +carry weight
|
||||||
end
|
end
|
||||||
for _, player in Players:GetPlayers() do
|
if STAGE.SeedInventory then
|
||||||
seedPlayer(player)
|
for _, player in Players:GetPlayers() do
|
||||||
|
seedPlayer(player)
|
||||||
|
end
|
||||||
|
Players.PlayerAdded:Connect(seedPlayer)
|
||||||
end
|
end
|
||||||
Players.PlayerAdded:Connect(seedPlayer)
|
|
||||||
|
|
||||||
SurvivorCore.start()
|
SurvivorCore.start()
|
||||||
|
|
||||||
@@ -509,10 +514,12 @@ local function buildReed(position: Vector3)
|
|||||||
reed.Parent = Workspace
|
reed.Parent = Workspace
|
||||||
end
|
end
|
||||||
|
|
||||||
buildTree(Vector3.new(10, 0, 28))
|
if STAGE.WorldContent then
|
||||||
buildTree(Vector3.new(18, 0, 28))
|
buildTree(Vector3.new(10, 0, 28))
|
||||||
buildReed(Vector3.new(-8, 0, 28))
|
buildTree(Vector3.new(18, 0, 28))
|
||||||
buildReed(Vector3.new(-12, 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.
|
-- 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)
|
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
|
-- 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).
|
-- 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 })
|
if STAGE.Mobs then
|
||||||
SurvivorCore.Mobs.spawn("husk", CFrame.new(82, 5, 58), { respawn = true })
|
SurvivorCore.Mobs.spawn("husk", CFrame.new(70, 5, 45), { respawn = true })
|
||||||
SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18))
|
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 —
|
-- 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.
|
-- 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")
|
CollectionService:AddTag(post, "QuestGiver")
|
||||||
post.Parent = Workspace
|
post.Parent = Workspace
|
||||||
end
|
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.
|
-- Flourish hooks: print quest + achievement milestones to the output.
|
||||||
SurvivorCore.Hooks.on("quest:completed", function(ctx)
|
SurvivorCore.Hooks.on("quest:completed", function(ctx)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|||||||
local Workspace = game:GetService("Workspace")
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
||||||
|
local Stage = require(script.Parent.Stage)
|
||||||
|
|
||||||
local PICKUPS = {
|
local PICKUPS = {
|
||||||
{ label = "PICK UP Stone Axe", item = "stone_axe", amount = 1, color = Color3.fromRGB(120, 130, 140) },
|
{ 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)
|
||||||
end
|
end
|
||||||
|
|
||||||
local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations
|
if Stage.TestStations then
|
||||||
for i, def in PICKUPS do
|
local BASE = Vector3.new(0, 4, 16) -- opposite side from the stat test stations
|
||||||
local x = (i - (#PICKUPS + 1) / 2) * 4
|
for i, def in PICKUPS do
|
||||||
buildPickup(def, BASE + Vector3.new(x, 0, 0))
|
local x = (i - (#PICKUPS + 1) / 2) * 4
|
||||||
|
buildPickup(def, BASE + Vector3.new(x, 0, 0))
|
||||||
|
end
|
||||||
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 Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
||||||
|
local Stage = require(script.Parent.Stage)
|
||||||
|
|
||||||
local POISON_SOURCE = "TestStation:poison"
|
local POISON_SOURCE = "TestStation:poison"
|
||||||
local BLEED_SOURCE = "TestStation:bleed"
|
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
|
-- (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.)
|
-- 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)
|
if Stage.TestStations then
|
||||||
for i, def in STATIONS do
|
local BASE = Vector3.new(0, 4, -16)
|
||||||
local x = (i - (#STATIONS + 1) / 2) * 4
|
for i, def in STATIONS do
|
||||||
buildStation(def, BASE + Vector3.new(x, 0, 0))
|
local x = (i - (#STATIONS + 1) / 2) * 4
|
||||||
|
buildStation(def, BASE + Vector3.new(x, 0, 0))
|
||||||
|
end
|
||||||
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:
|
window** (drag-dock it anywhere) with a sidebar of editors:
|
||||||
|
|
||||||
- **Overview** — is the engine synced, what content exists (click through), how the model works.
|
- **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`
|
- **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**.
|
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,
|
- **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
|
> 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.
|
> 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
|
## Install
|
||||||
|
|
||||||
@@ -66,6 +69,47 @@ the window.
|
|||||||
> Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring
|
> Rojo-synced place, so reconnect Rojo and re-sync (or save the place before restarting) to bring
|
||||||
> the engine back.
|
> 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
|
## Survival Stats
|
||||||
|
|
||||||
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
|
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
|
tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by
|
||||||
an internal `_scBound` attribute).
|
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
|
## 3. Hooks — react to engine lifecycle events
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Once published, add it to your game's `wally.toml`:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
SurvivorCore = "temujincalidius/survivorcore@0.9.0"
|
SurvivorCore = "temujincalidius/survivorcore@0.10.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
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 ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local Field = require(script.Parent.Field)
|
||||||
|
|
||||||
local ConfigAdmin = {}
|
local ConfigAdmin = {}
|
||||||
|
|
||||||
ConfigAdmin.INSTANCE_NAME = "SurvivorCoreEngineConfig"
|
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) ────────────────────────────────────────────
|
-- ── Schema (live from the engine) ────────────────────────────────────────────
|
||||||
|
|
||||||
local function findEngineConfigModule(): ModuleScript?
|
local function findEngineConfigModule(): ModuleScript?
|
||||||
@@ -126,113 +123,19 @@ end
|
|||||||
-- Parse a raw edit (usually TextBox text) per the field spec. Returns { ok, value } or
|
-- 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").
|
-- { ok = false, error }. A blank string is handled by setOverride (it means "reset").
|
||||||
function ConfigAdmin.coerce(field: any, raw: any): any
|
function ConfigAdmin.coerce(field: any, raw: any): any
|
||||||
if field.kind == "number" then
|
return Field.coerce(field, raw)
|
||||||
local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw))
|
|
||||||
if n == nil then
|
|
||||||
return { ok = false, error = "expected a number" }
|
|
||||||
end
|
|
||||||
if n ~= n or n == math.huge or n == -math.huge then
|
|
||||||
return { ok = false, error = "expected a finite number" }
|
|
||||||
end
|
|
||||||
if field.min then
|
|
||||||
n = math.max(n, field.min)
|
|
||||||
end
|
|
||||||
if field.max then
|
|
||||||
n = math.min(n, field.max)
|
|
||||||
end
|
|
||||||
if field.integer then
|
|
||||||
n = math.floor(n + 0.5)
|
|
||||||
end
|
|
||||||
return { ok = true, value = n }
|
|
||||||
elseif field.kind == "boolean" then
|
|
||||||
if typeof(raw) == "boolean" then
|
|
||||||
return { ok = true, value = raw }
|
|
||||||
end
|
|
||||||
local s = string.lower(tostring(raw))
|
|
||||||
if s == "true" then
|
|
||||||
return { ok = true, value = true }
|
|
||||||
elseif s == "false" then
|
|
||||||
return { ok = true, value = false }
|
|
||||||
end
|
|
||||||
return { ok = false, error = "expected true or false" }
|
|
||||||
elseif field.kind == "enum" then
|
|
||||||
local s = tostring(raw)
|
|
||||||
for _, choice in field.choices or {} :: { string } do
|
|
||||||
if choice == s then
|
|
||||||
return { ok = true, value = s }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return { ok = false, error = `must be one of: {table.concat(field.choices or {}, ", ")}` }
|
|
||||||
elseif field.kind == "color3" then
|
|
||||||
if typeof(raw) == "Color3" then
|
|
||||||
return { ok = true, value = raw }
|
|
||||||
end
|
|
||||||
local r, g, b = string.match(tostring(raw), "^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$")
|
|
||||||
if not r then
|
|
||||||
return { ok = false, error = "expected R, G, B (0-255 each)" }
|
|
||||||
end
|
|
||||||
local rn, gn, bn = tonumber(r), tonumber(g), tonumber(b)
|
|
||||||
if rn > 255 or gn > 255 or bn > 255 then
|
|
||||||
return { ok = false, error = "channels are 0-255" }
|
|
||||||
end
|
|
||||||
return { ok = true, value = Color3.fromRGB(rn, gn, bn) }
|
|
||||||
elseif field.kind == "font" then
|
|
||||||
local s = tostring(raw)
|
|
||||||
local ok, font = pcall(function()
|
|
||||||
return (Enum.Font :: any)[s]
|
|
||||||
end)
|
|
||||||
if not ok or typeof(font) ~= "EnumItem" then
|
|
||||||
return { ok = false, error = `'{s}' is not an Enum.Font name` }
|
|
||||||
end
|
|
||||||
return { ok = true, value = s } -- stored as the NAME string; the engine resolves it
|
|
||||||
else -- "string" (incl. check = keycode/assetId)
|
|
||||||
local s = tostring(raw)
|
|
||||||
if field.check == "keycode" then
|
|
||||||
local ok, keyCode = pcall(function()
|
|
||||||
return (Enum.KeyCode :: any)[s]
|
|
||||||
end)
|
|
||||||
if not ok or typeof(keyCode) ~= "EnumItem" then
|
|
||||||
return { ok = false, error = `'{s}' is not an Enum.KeyCode name` }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return { ok = true, value = s }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Does a coerced value equal the engine default (→ store nothing)? Kind-aware: fonts compare
|
-- 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
|
-- the stored NAME against the default Enum.Font; Color3 compares exactly (both sides come from
|
||||||
-- fromRGB construction); numbers use the relative epsilon.
|
-- fromRGB construction); numbers use the relative epsilon.
|
||||||
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
|
function ConfigAdmin.equalsDefault(field: any, value: any, default: any): boolean
|
||||||
if field.kind == "number" then
|
return Field.equalsDefault(field, value, default)
|
||||||
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
|
end
|
||||||
|
|
||||||
-- Display formatting for effective values/defaults (what the TextBox shows).
|
-- Display formatting for effective values/defaults (what the TextBox shows).
|
||||||
function ConfigAdmin.formatValue(field: any, value: any): string
|
function ConfigAdmin.formatValue(field: any, value: any): string
|
||||||
if value == nil then
|
return Field.format(field, value)
|
||||||
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
|
end
|
||||||
|
|
||||||
-- ── Writes (the deltas-only decision point) ──────────────────────────────────
|
-- ── 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
|
}) :: Frame
|
||||||
end
|
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).
|
-- A small count/status pill (rail badges, entry badges).
|
||||||
function Theme.badge(text: string, textColor: Color3?): TextLabel
|
function Theme.badge(text: string, textColor: Color3?): TextLabel
|
||||||
return Theme.make("TextLabel", {
|
return Theme.make("TextLabel", {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ local ContentAdmin = require(script.ContentAdmin)
|
|||||||
local ContentAdminUi = require(script.ContentAdminUi)
|
local ContentAdminUi = require(script.ContentAdminUi)
|
||||||
local ConfigAdmin = require(script.ConfigAdmin)
|
local ConfigAdmin = require(script.ConfigAdmin)
|
||||||
local ConfigAdminUi = require(script.ConfigAdminUi)
|
local ConfigAdminUi = require(script.ConfigAdminUi)
|
||||||
|
local BuildAdmin = require(script.BuildAdmin)
|
||||||
|
local BuildAdminUi = require(script.BuildAdminUi)
|
||||||
|
|
||||||
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
local SETTING_LAST_PAGE = "SurvivorCoreStudio.lastPage"
|
||||||
|
|
||||||
@@ -112,6 +114,39 @@ local function applyConfigResetSection(section: any): any
|
|||||||
end)
|
end)
|
||||||
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 ──────────
|
-- ── Content callbacks (authored + overrides), bundled for the pages ──────────
|
||||||
local actions = {
|
local actions = {
|
||||||
set = function(catKey: string, id: string, field: any, raw: any): any
|
set = function(catKey: string, id: string, field: any, raw: any): any
|
||||||
@@ -185,6 +220,17 @@ local function mountUi()
|
|||||||
end,
|
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, {
|
table.insert(pages, {
|
||||||
id = "stats",
|
id = "stats",
|
||||||
label = "Survival Stats",
|
label = "Survival Stats",
|
||||||
|
|||||||
+5
-3
@@ -55,7 +55,7 @@
|
|||||||
<div class="hero-inner">
|
<div class="hero-inner">
|
||||||
<img class="hero-logo" src="assets/img/logo-256.png" alt="SurvivorCore logo" width="112" height="112" />
|
<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">
|
<a class="badge" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">
|
||||||
v0.9.0 · pre-release
|
v0.10.0 · pre-release
|
||||||
</a>
|
</a>
|
||||||
<h1>The survival game engine for <span class="accent">Roblox</span></h1>
|
<h1>The survival game engine for <span class="accent">Roblox</span></h1>
|
||||||
<p class="lede">
|
<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>
|
<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>
|
</span>
|
||||||
<h3>SurvivorCore Studio — no-code admin</h3>
|
<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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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/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/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/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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -195,7 +197,7 @@
|
|||||||
<p>Grab the drop-in model from the latest release, or add it with Wally:</p>
|
<p>Grab the drop-in model from the latest release, or add it with Wally:</p>
|
||||||
<pre><code># wally.toml
|
<pre><code># wally.toml
|
||||||
[dependencies]
|
[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 class="muted">…or drop <code>SurvivorCore.rbxm</code> into <code>ReplicatedStorage</code>.</p>
|
||||||
<p>
|
<p>
|
||||||
<a class="btn btn-ghost" href="https://github.com/TemujinCalidius/SurvivorCore/releases/latest">Latest release ↗</a>
|
<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 Components = require(script.Parent)
|
||||||
|
local Schema = require(script.Parent.Schema)
|
||||||
local Registries = require(script.Parent.Parent.registries)
|
local Registries = require(script.Parent.Parent.registries)
|
||||||
local Harvesting = require(script.Parent.Parent.systems.Harvesting)
|
local Harvesting = require(script.Parent.Parent.systems.Harvesting)
|
||||||
|
|
||||||
@@ -64,22 +65,15 @@ local function resolve(values: { [string]: any })
|
|||||||
}
|
}
|
||||||
end
|
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({
|
return Components.define({
|
||||||
name = "Gatherable",
|
name = SCHEMA.name,
|
||||||
tag = "Gatherable",
|
tag = SCHEMA.tag,
|
||||||
attributes = {
|
display = SCHEMA.display,
|
||||||
Resource = "", -- named resource def to inherit from (optional)
|
attributes = SCHEMA.attributes,
|
||||||
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
|
|
||||||
},
|
|
||||||
onSetup = function(instance, values)
|
onSetup = function(instance, values)
|
||||||
local r = resolve(values)
|
local r = resolve(values)
|
||||||
|
|
||||||
|
|||||||
+9
-15
@@ -12,24 +12,18 @@
|
|||||||
]]
|
]]
|
||||||
|
|
||||||
local Components = require(script.Parent)
|
local Components = require(script.Parent)
|
||||||
|
local Schema = require(script.Parent.Schema)
|
||||||
local Mobs = require(script.Parent.Parent.systems.Mobs)
|
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({
|
return Components.define({
|
||||||
name = "Mob",
|
name = SCHEMA.name,
|
||||||
tag = "Mob",
|
tag = SCHEMA.tag,
|
||||||
attributes = {
|
display = SCHEMA.display,
|
||||||
MobType = "", -- the Mobs def id to inherit from; blank = use the model's Name
|
attributes = SCHEMA.attributes,
|
||||||
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
|
|
||||||
},
|
|
||||||
onSetup = function(instance, _values)
|
onSetup = function(instance, _values)
|
||||||
-- The server Mobs runtime reads the instance attributes directly (one merge path:
|
-- 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.
|
-- override attr › registry def › "Mobs" Config), so nothing to stash here — just adopt it.
|
||||||
|
|||||||
@@ -13,16 +13,20 @@
|
|||||||
]]
|
]]
|
||||||
|
|
||||||
local Components = require(script.Parent)
|
local Components = require(script.Parent)
|
||||||
|
local Schema = require(script.Parent.Schema)
|
||||||
local Registries = require(script.Parent.Parent.registries)
|
local Registries = require(script.Parent.Parent.registries)
|
||||||
local Quests = require(script.Parent.Parent.systems.Quests)
|
local Quests = require(script.Parent.Parent.systems.Quests)
|
||||||
local QuestData = require(script.Parent.Parent.shared.QuestData)
|
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({
|
return Components.define({
|
||||||
name = "QuestGiver",
|
name = SCHEMA.name,
|
||||||
tag = "QuestGiver",
|
tag = SCHEMA.tag,
|
||||||
attributes = {
|
display = SCHEMA.display,
|
||||||
Quest = "", -- the quest id this giver offers / accepts turn-ins for
|
attributes = SCHEMA.attributes,
|
||||||
},
|
|
||||||
onSetup = function(instance, values)
|
onSetup = function(instance, values)
|
||||||
local questId = tostring(values.Quest or "")
|
local questId = tostring(values.Quest or "")
|
||||||
if questId == "" then
|
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
|
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
|
instances and watch for new ones.
|
||||||
visually instead of by hand.
|
|
||||||
|
`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 CollectionService = game:GetService("CollectionService")
|
||||||
|
|
||||||
|
local Schema = require(script.Schema)
|
||||||
|
|
||||||
local Components = {}
|
local Components = {}
|
||||||
|
|
||||||
|
export type AttributeSpec = Schema.AttributeSpec
|
||||||
|
|
||||||
export type Spec = {
|
export type Spec = {
|
||||||
name: string,
|
name: string,
|
||||||
tag: 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 }) -> ())?,
|
onSetup: ((instance: Instance, values: { [string]: any }) -> ())?,
|
||||||
}
|
}
|
||||||
|
|
||||||
local defined: { [string]: Spec } = {}
|
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
|
function Components.define(spec: Spec): Spec
|
||||||
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
|
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
|
||||||
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
|
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
|
||||||
|
local attributes = Schema.normalize(spec.attributes)
|
||||||
defined[spec.name] = spec
|
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
|
return spec
|
||||||
end
|
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 function readAttributes(instance: Instance, attributes: { [string]: any }?)
|
||||||
local values = {}
|
local values = {}
|
||||||
if attributes then
|
if attributes then
|
||||||
@@ -52,7 +96,8 @@ local function bind(instance: Instance, spec: Spec)
|
|||||||
end
|
end
|
||||||
instance:SetAttribute("_scBound", true)
|
instance:SetAttribute("_scBound", true)
|
||||||
if spec.onSetup then
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ local EngineConfig = require(script.shared.EngineConfig)
|
|||||||
|
|
||||||
local SurvivorCore = {}
|
local SurvivorCore = {}
|
||||||
|
|
||||||
SurvivorCore.VERSION = "0.9.0"
|
SurvivorCore.VERSION = "0.10.0"
|
||||||
|
|
||||||
-- Foundation
|
-- Foundation
|
||||||
SurvivorCore.Config = Config
|
SurvivorCore.Config = Config
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "temujincalidius/survivorcore"
|
name = "temujincalidius/survivorcore"
|
||||||
description = "Batteries-included, creator-extensible survival game framework for Roblox."
|
description = "Batteries-included, creator-extensible survival game framework for Roblox."
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Samuel Lison"]
|
authors = ["Samuel Lison"]
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
|
|||||||
Reference in New Issue
Block a user