mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
Select a Part or Model in Studio, answer "what is this object?", fill a form — it becomes a gatherable node, a mob or a quest giver. Closes the gap between authoring a def and setting up a world object, which until now meant knowing to tag a part and hand-typing PascalCase attributes in the property panel. Engine — components can declare an attribute SCHEMA: - src/components/Schema.luau (new): AttributeSpec/Display/ComponentSchema types, normalize/defaults/get/list, and the schemas for Gatherable, Mob, QuestGiver. Dependency-free ON PURPOSE: the plugin requires it live at edit time, and the component modules themselves can't be required there (Harvesting asserts IsServer; Remotes creates instances in ReplicatedStorage). - Components.define now accepts EITHER the legacy `attr = default` map or a schema array, normalizing both to one ordered spec list; bind() reads the derived default map, so binding is byte-identical. Legacy maps are sorted, as `pairs` order is arbitrary and would make a UI jitter. New getSchema/ listSchemas. The three shipped components pull name/tag/display/attributes from the schema; their onSetup bodies are untouched (defaults verified identical, all 23 attributes). Plugin — the Build page: - Field.luau (new): coerce/format/equalsDefault, lifted from ConfigAdmin (which now delegates), shared by every schema-driven editor. - FieldRow.luau (new): the shared [○/●] label … control + help row, including a ⌄ picker that cycles authored ids for fields declaring `ref`. - BuildAdmin.luau (new): live schema read with three distinct empty states, selection/eligibility/identify, deltas-only attribute writes, applyType (tag + clear any other component) and clear. - BuildAdminUi.luau (new): chooser cards, grouped form, multi-select apply, stale-bind-marker warning, SelectionChanged-driven refresh. - init.server.luau: record()-wrapped buildActions + the page. Docs: docs/admin-plugin.md Build section + a 60-second walkthrough, docs/extending.md schema guide, a CONTRIBUTING rule that new creator components declare one, CHANGELOG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
307 lines
12 KiB
Markdown
307 lines
12 KiB
Markdown
# Extending SurvivorCore
|
|
|
|
SurvivorCore ships **mechanics, not content**. You bring the items, world, art, and rules;
|
|
the engine wires up behavior. There are three ways to plug in, in rough order of how often
|
|
you'll reach for them:
|
|
|
|
1. **[Registries](#1-registries--register-content-from-code)** — register content from code
|
|
(`Items`, `Recipes`, `Stats`, `Mobs`, …).
|
|
2. **[Components](#2-components--tag-your-own-objects)** — tag your own objects and set
|
|
Attributes; no engine-side definition needed.
|
|
3. **[Hooks](#3-hooks--react-to-engine-lifecycle-events)** — react to engine lifecycle events
|
|
to add game-specific flourish without forking the engine.
|
|
|
|
Two foundation services — [`Config`](#config--tune-the-engine) and
|
|
[`Assets`](#assets--keep-ids-out-of-code) — support all three.
|
|
|
|
Everything below assumes you've required the engine and will call `SurvivorCore.start()`
|
|
**once, from the server, after** registering your content:
|
|
|
|
```lua
|
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Registries — register content from code
|
|
|
|
A registry is an empty table the engine owns and your game fills. Every registry shares the
|
|
same lifecycle — `register` a definition, it's validated and indexed by a key field, and you
|
|
query it back. The engine ships **zero** concrete entries.
|
|
|
|
```lua
|
|
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
|
|
SurvivorCore.Items.register({ id = "reed_basket", name = "Reed Basket", stack = 1 })
|
|
|
|
SurvivorCore.Recipes.register({
|
|
id = "reed_basket",
|
|
station = "hand", -- "hand", "campfire", … — just a routing tag
|
|
ingredients = { { item = "reed", count = 5 } },
|
|
output = { item = "reed_basket", count = 1 },
|
|
})
|
|
```
|
|
|
|
### The registries
|
|
|
|
| Registry | Key field | What it holds |
|
|
|---|---|---|
|
|
| `Items` | `id` | Item definitions (name, stack size, …) — incl. weapons + ammo. |
|
|
| `Recipes` | `id` | Crafting **and** cooking recipes — one registry, routed by `station`. |
|
|
| `Resources` | `id` | Gatherable-resource defs (what a tagged node *is*). |
|
|
| `Stats` | `name` | Survival/status stat models. |
|
|
| `Achievements` | `key` | Achievement defs (counter + threshold — [docs](achievements.md)). |
|
|
| `Quests` | `id` | Quest defs (objectives + rewards — [docs](quests.md)). |
|
|
| `Codex` | `id` | Discoverable lore / collectible entries. |
|
|
| `Appearance` | `id` | Character appearance options. |
|
|
| `Mobs` | `id` | Creature / hostile-mob definitions. |
|
|
|
|
### Shared API (every registry)
|
|
|
|
```lua
|
|
local Items = SurvivorCore.Items
|
|
|
|
Items.register(def) -- add one; errors on missing/duplicate key
|
|
Items.registerMany({ a, b }) -- add several
|
|
Items.get("reed") -- fetch by key, or nil
|
|
Items.getAll() -- array of every def
|
|
Items.query(function(d) -- filtered array
|
|
return d.stack == 1
|
|
end)
|
|
```
|
|
|
|
Some registries add convenience helpers — e.g. recipes by station:
|
|
|
|
```lua
|
|
SurvivorCore.Recipes.forStation("campfire")
|
|
SurvivorCore.Stats.defineStat({ name = "Thirst", max = 100 }) -- alias of Stats.register
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Components — tag your own objects
|
|
|
|
When the *content is a Roblox object you built*, you don't need a code-side definition at all.
|
|
Tag your mesh with a [CollectionService](https://create.roblox.com/docs/reference/engine/classes/CollectionService)
|
|
tag and set per-instance **Attributes**; the engine binds behavior to it.
|
|
|
|
The flagship component is `Gatherable`. Build any part/mesh, tag it `Gatherable`, and set:
|
|
|
|
| Attribute | Type | Meaning |
|
|
|---|---|---|
|
|
| `ItemId` | string | what it yields |
|
|
| `Yield` | number | amount per full harvest |
|
|
| `HP` | number | interactions to deplete |
|
|
|
|
That's it — the engine adds a ProximityPrompt and runs the harvest loop. Attributes can be set
|
|
in Studio's Properties panel or from code; a future builder UI will set them visually.
|
|
|
|
### Defining your own component
|
|
|
|
```lua
|
|
SurvivorCore.Components.define({
|
|
name = "Campfire",
|
|
tag = "Campfire",
|
|
attributes = { -- attribute name -> default value
|
|
FuelSeconds = 60,
|
|
Lit = false,
|
|
},
|
|
onSetup = function(instance, values)
|
|
-- `values` is the resolved attributes (instance value, else the default).
|
|
-- Wire up prompts, signals, etc. here.
|
|
end,
|
|
})
|
|
```
|
|
|
|
`SurvivorCore.start()` calls `Components.scan()` for you, which binds everything currently
|
|
tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by
|
|
an internal `_scBound` attribute).
|
|
|
|
Attributes prefixed with **`_`** are engine-internal (the `_scBound` bind marker, and any values a
|
|
component resolves and stashes for its server system). Never author them by hand.
|
|
|
|
### Declaring a schema (so the Builder can render a form)
|
|
|
|
`attributes` also accepts a **schema array**, which adds the kind, label, help text and options for
|
|
each attribute. That's what lets the Studio plugin's **Build** page render a setup form for your
|
|
component — select a part, pick your component, fill the fields — with **no UI code on your side**:
|
|
|
|
```lua
|
|
SurvivorCore.Components.define({
|
|
name = "Campfire",
|
|
tag = "Campfire",
|
|
display = {
|
|
title = "Campfire",
|
|
summary = "A fire players light for warmth and cooking.",
|
|
instance = "any", -- or "Model" / "BasePart" — what the Builder will let you tag
|
|
},
|
|
attributes = {
|
|
{ attr = "FuelSeconds", kind = "number", label = "Fuel (seconds)", default = 60, min = 0,
|
|
help = "How long one load of fuel burns." },
|
|
{ attr = "Lit", kind = "boolean", label = "Starts lit", default = false },
|
|
},
|
|
onSetup = function(instance, values)
|
|
-- `values` is identical either way: the instance's attribute, else the default.
|
|
end,
|
|
})
|
|
```
|
|
|
|
Both forms bind identically — the shorthand map is still supported and nothing changes for
|
|
components that use it. Field `kind` is `number` | `boolean` | `string` | `enum` (with `choices`);
|
|
a field may also carry `min`/`max`/`integer`, a `placeholder`, a `group` (form section), and `ref`
|
|
(a content category whose authored ids the Builder offers as a picker). The engine's own components
|
|
declare theirs in [`src/components/Schema.luau`](../src/components/Schema.luau).
|
|
|
|
**Convention:** every creator-facing component should declare a schema, so it is Builder-drivable
|
|
rather than requiring the creator to know attribute names.
|
|
|
|
---
|
|
|
|
## 3. Hooks — react to engine lifecycle events
|
|
|
|
Hooks are where game-specific flourish lives **outside** the engine. Where a registry says
|
|
"here is my content" and a component says "here is my object," a hook says "engine, when X
|
|
happens, run my code." This is how The Counter Earth's trees physically fall and segment into
|
|
logs while SurvivorCore itself ships none of that.
|
|
|
|
```lua
|
|
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
|
|
-- ctx = { instance, player, values }
|
|
spawnFallingTreePhysics(ctx.instance) -- your game's flair
|
|
grantBonusDrop(ctx.player, ctx.values.ItemId)
|
|
end)
|
|
```
|
|
|
|
`Hooks.on` returns a disconnect function:
|
|
|
|
```lua
|
|
local disconnect = SurvivorCore.Hooks.on("gather:hit", function(ctx)
|
|
-- ctx = { instance, player, values, hpLeft }
|
|
flashOutline(ctx.instance)
|
|
end)
|
|
-- later: disconnect()
|
|
```
|
|
|
|
Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue lives in the header of
|
|
[`src/foundation/Hooks.luau`](../src/foundation/Hooks.luau); highlights:
|
|
|
|
| Hook family | Fired by |
|
|
|---|---|
|
|
| `gather:hit` / `gather:depleted` / `gather:blocked` | harvesting ([docs](harvesting.md)) |
|
|
| `craft:start` / `craft:end` / `craft:blocked` | crafting ([docs](crafting.md)) |
|
|
| `item:use` · `inventory:changed` | inventory ([docs](inventory.md)) |
|
|
| `mob:spawned` / `mob:hit` / `mob:attack` / `mob:died` | mobs & AI ([docs](mobs.md)) |
|
|
| `combat:hit` / `combat:kill` | combat ([docs](combat.md)) |
|
|
| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) |
|
|
| `achievement:unlocked` | achievements ([docs](achievements.md)) |
|
|
| `player:died` · `lootbag:dropped` / `lootbag:collected` | death & loot bags ([docs](loot-bags.md)) |
|
|
| `trade:started` / `trade:completed` | player trading ([docs](trading.md)) |
|
|
|
|
These gameplay events ALSO cross the **EventBridge** with the same names — that bus is what quests,
|
|
achievements, and analytics consume (via the `Progression` translation layer,
|
|
[docs](achievements.md#the-progression-stream)).
|
|
|
|
### Hooks vs. EventBridge
|
|
|
|
- **Hooks** = "the engine is about to / just did X — creators, do your thing here." Scoped,
|
|
lifecycle-shaped extension points.
|
|
- **[EventBridge](architecture.md#foundation)** = a semantic event *bus* for things that
|
|
happened (`animal_killed`, …), which any number of decoupled subscribers (achievements,
|
|
quests, analytics, a webhook) can observe:
|
|
|
|
```lua
|
|
local disconnect = SurvivorCore.Events.onFire(function(eventType, player, data)
|
|
if eventType == "animal_killed" then
|
|
analytics:track(player, "hunt", data)
|
|
end
|
|
end)
|
|
```
|
|
|
|
---
|
|
|
|
## `Config` — tune the engine
|
|
|
|
The engine declares default tunables per section; your game overrides them via deep merge.
|
|
|
|
```lua
|
|
-- (engine declares defaults internally, e.g.)
|
|
-- Config.defineSection("Energy", { DrainPerSecond = 16, RegenPerSecond = 14 })
|
|
|
|
SurvivorCore.Config.override("Energy", { DrainPerSecond = 10 }) -- your tweak
|
|
SurvivorCore.Config.get("Energy.DrainPerSecond") -- 10
|
|
```
|
|
|
|
Full resolution order (each layer wins over the previous):
|
|
|
|
1. **Engine defaults** — `defineSection` at require time.
|
|
2. **Your game's `Config.override(...)`** — code-time tuning, any time before `start()`.
|
|
3. **The `SurvivorCoreEngineConfig` instance** — the no-code layer the
|
|
[admin plugin's Engine Config editor](admin-plugin.md) writes (deltas-only). Layered over
|
|
everything as the first step of `start()`/`startClient()`, so it applies on the next Play.
|
|
(`SurvivalStatsConfig` is its live-applied sibling for survival stats.)
|
|
|
|
## `Assets` — keep IDs out of code
|
|
|
|
The engine **never** hardcodes asset IDs — your game registers them and the engine reads them
|
|
back, with a safe empty-string fallback if one's missing. This is a hard rule for engine code
|
|
and the right pattern for your content too.
|
|
|
|
```lua
|
|
SurvivorCore.Assets.register("Sounds", "Harvest", "rbxassetid://123456789")
|
|
SurvivorCore.Assets.registerCategory("Animations", {
|
|
Idle = "rbxassetid://111",
|
|
Chop = "rbxassetid://222",
|
|
})
|
|
|
|
SurvivorCore.Assets.get("Sounds", "Harvest") -- "rbxassetid://123456789"
|
|
```
|
|
|
|
Two engine-reserved categories resolve automatically in the built-in UI: **`StatIcons`** (per stat,
|
|
for the HUD) and **`ItemIcons`** (per item id, for the inventory). An item icon can also live inline on
|
|
the def's `icon` field — either works.
|
|
|
|
```lua
|
|
SurvivorCore.Assets.register("ItemIcons", "reed", "rbxassetid://…")
|
|
```
|
|
|
|
---
|
|
|
|
## Inventory & UI
|
|
|
|
The engine ships a full inventory (slots + carry-weight), a hotbar, equipment slots, and a tabbed
|
|
menu — all driven by the same template + binder model as the HUD, and extensible from code:
|
|
|
|
```lua
|
|
SurvivorCore.Inventory.add(player, "reed", 5) -- server: grant items
|
|
SurvivorCore.UI.registerPanel({ id = "map", title = "Map", build = fn }) -- client: add a tab
|
|
```
|
|
|
|
See **[Inventory, Hotbar & Menu UI](inventory.md)** for the item-def schema, the full API, the
|
|
RemoteEvent contract, the `item:use` / `inventory:changed` hooks, and the template attribute
|
|
conventions.
|
|
|
|
---
|
|
|
|
## Putting it together
|
|
|
|
```lua
|
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
|
|
|
-- content
|
|
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
|
|
SurvivorCore.Assets.register("Sounds", "Harvest", "rbxassetid://123456789")
|
|
|
|
-- flourish
|
|
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
|
|
print(ctx.player.Name, "harvested", ctx.values.ItemId)
|
|
end)
|
|
|
|
-- go
|
|
SurvivorCore.start()
|
|
```
|
|
|
|
See [Getting Started](getting-started.md) to wire the engine into your place,
|
|
[Survival Stats + HUD](survival-stats.md) for the built-in stats and HUD, and
|
|
[Architecture](architecture.md) for how the layers fit together.
|