mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
Scaffold SurvivorCore foundation (v0.1.0)
Foundation (Config/Assets/EventBridge/Hooks/Registry), content registry family, creator-owned component layer + Gatherable example, runnable demo place, Rojo/Wally/Rokit config, and release CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
name: Release
|
||||
|
||||
# Build the drop-in .rbxm from source on every version tag and attach it to the release.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install toolchain (rojo via rokit)
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: Build engine model
|
||||
run: rojo build default.project.json --output SurvivorCore.rbxm
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: SurvivorCore.rbxm
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Rojo / Roblox
|
||||
sourcemap.json
|
||||
*.rbxl
|
||||
*.rbxlx
|
||||
*.rbxl.lock
|
||||
*.rbxlx.lock
|
||||
|
||||
# Build output (the .rbxm is built in CI and attached to releases)
|
||||
*.rbxm
|
||||
*.rbxmx
|
||||
/build/
|
||||
|
||||
# Toolchain managers
|
||||
/.rokit/
|
||||
|
||||
# Wally
|
||||
/Packages/
|
||||
/ServerPackages/
|
||||
/DevPackages/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.vscode/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Samuel Lison
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,80 @@
|
||||
<div align="center">
|
||||
|
||||
# SurvivorCore
|
||||
|
||||
**A batteries-included, creator-extensible survival game framework for Roblox.**
|
||||
|
||||
</div>
|
||||
|
||||
> ⚠️ **Status: early scaffold (v0.1.0).** The foundation layer and architecture are in
|
||||
> place; survival systems are being extracted from a production game ([The Counter
|
||||
> Earth](https://github.com/TemujinCalidius/TheCounterEarth)) into this engine. APIs will
|
||||
> change. Not yet production-ready.
|
||||
|
||||
## What it is
|
||||
|
||||
SurvivorCore gives you the *mechanics* of a survival game — stats (energy/hunger/thirst…),
|
||||
inventory, crafting, cooking, harvesting, hostile mobs & combat, achievements, a codex, and
|
||||
player persistence — without dictating your *content*. You bring your own items, world, and
|
||||
art; the engine wires up the behavior.
|
||||
|
||||
## Two ways to extend it
|
||||
|
||||
**1. Programmatic — register content from code:**
|
||||
|
||||
```lua
|
||||
local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
|
||||
|
||||
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
|
||||
SurvivorCore.Recipes.register({
|
||||
id = "reed_basket", station = "hand",
|
||||
ingredients = { { item = "reed", count = 5 } },
|
||||
output = { item = "reed_basket", count = 1 },
|
||||
})
|
||||
|
||||
SurvivorCore.start()
|
||||
```
|
||||
|
||||
**2. Creator-owned — attach behavior to your own objects:**
|
||||
|
||||
Build *any* mesh, tag it `Gatherable`, and set attributes — no engine-side definition needed:
|
||||
|
||||
| Attribute | Meaning |
|
||||
|---|---|
|
||||
| `ItemId` | what it yields |
|
||||
| `Yield` | amount per full harvest |
|
||||
| `HP` | hits to deplete |
|
||||
|
||||
The same pattern extends to craftables, huntables, farmables, and more. For advanced,
|
||||
game-specific behavior (e.g. trees that physically fall and cut into pieces), hook into
|
||||
engine lifecycle events instead of forking the engine:
|
||||
|
||||
```lua
|
||||
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
|
||||
-- your custom drop / VFX / physics here
|
||||
end)
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
- **Developers** — consume via [Rojo](https://rojo.space) (clone + `rojo serve`) or, once
|
||||
published, as a [Wally](https://wally.run) package.
|
||||
- **Drop-in** — grab `SurvivorCore.rbxm` from a [Release](../../releases) (auto-built from
|
||||
source via `rojo build`), drop it into `ReplicatedStorage`, and add a short bootstrap
|
||||
script that `require`s it and calls `.start()`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
init.luau -- SurvivorCore root: .start() + the public API
|
||||
foundation/ -- Config, Assets, EventBridge, Hooks, Registry (the core plumbing)
|
||||
registries/ -- Items, Recipes, Stats, Achievements, Codex, Appearance, Mobs
|
||||
components/ -- creator-facing tag/attribute components (e.g. Gatherable)
|
||||
demo/ -- a runnable demo place that consumes the engine
|
||||
docs/architecture.md -- how the layers fit together
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 Samuel Lison
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "SurvivorCore",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "SurvivorCore Demo",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"SurvivorCore": {
|
||||
"$path": "src"
|
||||
}
|
||||
},
|
||||
"ServerScriptService": {
|
||||
"Demo": {
|
||||
"$path": "demo/server"
|
||||
}
|
||||
},
|
||||
"Workspace": {
|
||||
"$ignoreUnknownInstances": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
--[[
|
||||
Demo boot script. Shows both extension layers:
|
||||
1. registering content from code, and
|
||||
2. reacting to creator-owned components via hooks.
|
||||
|
||||
Try it: serve `demo.project.json` into a place, add a Part to Workspace, tag it
|
||||
"Gatherable" (CollectionService), set attributes ItemId="reed", Yield=2, HP=3,
|
||||
then play and interact with it.
|
||||
]]
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
|
||||
|
||||
-- 1. Programmatic content
|
||||
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",
|
||||
ingredients = { { item = "reed", count = 5 } },
|
||||
output = { item = "reed_basket", count = 1 },
|
||||
})
|
||||
|
||||
-- 2. React to creator-owned Gatherables
|
||||
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
|
||||
print(("[demo] %s fully gathered %s"):format(ctx.player.Name, tostring(ctx.values.ItemId)))
|
||||
end)
|
||||
|
||||
SurvivorCore.start()
|
||||
print("SurvivorCore demo booted — v" .. SurvivorCore.VERSION)
|
||||
@@ -0,0 +1,52 @@
|
||||
# SurvivorCore — Architecture
|
||||
|
||||
> Companion to the boundary map in the TheCounterEarth repo
|
||||
> (`docs/survivorcore-boundary-map.md`), which tracks what is being extracted from the
|
||||
> production game into this engine.
|
||||
|
||||
## The shape
|
||||
|
||||
SurvivorCore is an **engine of mechanics, not content**. It exposes two extension layers
|
||||
over a small foundation.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
Creators → │ Component layer (tag + attributes + UI) │ "I own the object"
|
||||
├─────────────────────────────────────────────┤
|
||||
Developers → │ Registry layer (register() from code) │ "I own the data"
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Foundation: Config · Assets · EventBridge · │
|
||||
│ Hooks · Registry │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Foundation
|
||||
- **Config** — engine ships default tunables per section; games override via deep merge.
|
||||
- **Assets** — typed asset-id registry; the engine never hardcodes ids.
|
||||
- **EventBridge** — semantic event bus (`fire`/`onFire`); subscribers decouple from sources.
|
||||
- **Hooks** — lifecycle extension points (`Hooks.on("craft:start", ...)`).
|
||||
- **Registry** — the shared register/validate/index/query lifecycle behind every registry.
|
||||
|
||||
### Registry layer (developers)
|
||||
Empty registries the game populates at startup: `Items`, `Recipes` (crafting + cooking are
|
||||
one registry routed by `station`), `Stats`, `Achievements`, `Codex`, `Appearance`, `Mobs`.
|
||||
|
||||
### Component layer (creators)
|
||||
Behaviors bound to a CollectionService tag, configured by per-instance Attributes. Creators
|
||||
tag their **own** meshes (`Gatherable`, and later craftable/huntable/farmable/station
|
||||
components) and fill in values — optionally through a builder UI. No engine-side definition
|
||||
required.
|
||||
|
||||
## Why hooks instead of baked-in behavior
|
||||
|
||||
Game-specific flourish stays out of the engine. TheCounterEarth's trees physically fall and
|
||||
segment into logs — SurvivorCore will **not** ship that. Instead it fires
|
||||
`gather:hit` / `gather:depleted` (and similar) hooks; a creator (including TheCounterEarth
|
||||
itself, going forward) implements the felling physics in their own hook handler. The engine
|
||||
stays small and universal; creativity lives at the edges.
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
v0.1.0 is the foundation scaffold. Extraction order (from the boundary map): foundations →
|
||||
pure-core lift → registries → SPLIT server systems (incl. the mob/combat cluster) → UI
|
||||
layer → harvesting/wildlife sub-engine.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Toolchain pins (https://github.com/rojo-rbx/rokit). Run `rokit install`.
|
||||
[tools]
|
||||
rojo = "rojo-rbx/rojo@7.6.1"
|
||||
wally = "UpliftGames/wally@0.3.2"
|
||||
@@ -0,0 +1,55 @@
|
||||
--[[
|
||||
Gatherable — the flagship creator-owned component.
|
||||
|
||||
A creator builds ANY part/mesh, tags it "Gatherable", and sets attributes:
|
||||
• ItemId (string) — what it yields
|
||||
• Yield (number) — amount per full harvest
|
||||
• HP (number) — interactions to deplete
|
||||
|
||||
No engine-side content needed: the creator owns the object. Advanced behavior
|
||||
(custom drops, VFX, falling physics) attaches via Hooks rather than editing core.
|
||||
]]
|
||||
|
||||
local Components = require(script.Parent)
|
||||
local Hooks = require(script.Parent.Parent.foundation.Hooks)
|
||||
|
||||
return Components.define({
|
||||
name = "Gatherable",
|
||||
tag = "Gatherable",
|
||||
attributes = {
|
||||
ItemId = "unknown",
|
||||
Yield = 1,
|
||||
HP = 3,
|
||||
},
|
||||
onSetup = function(instance, values)
|
||||
instance:SetAttribute("_HP", values.HP)
|
||||
|
||||
local host = if instance:IsA("BasePart")
|
||||
then instance
|
||||
else instance:FindFirstChildWhichIsA("BasePart")
|
||||
if not host then
|
||||
warn(`[Gatherable] '{instance:GetFullName()}' has no BasePart to host a prompt`)
|
||||
return
|
||||
end
|
||||
|
||||
local prompt = Instance.new("ProximityPrompt")
|
||||
prompt.ActionText = "Gather"
|
||||
prompt.ObjectText = tostring(values.ItemId)
|
||||
prompt.HoldDuration = 0.4
|
||||
prompt.Parent = host
|
||||
|
||||
prompt.Triggered:Connect(function(player)
|
||||
local hp = (instance:GetAttribute("_HP") or 1) - 1
|
||||
instance:SetAttribute("_HP", hp)
|
||||
|
||||
-- TODO (extraction): grant `values.Yield` of `values.ItemId` via the
|
||||
-- inventory service once persistence/inventory are extracted.
|
||||
Hooks.run("gather:hit", { instance = instance, player = player, values = values, hpLeft = hp })
|
||||
|
||||
if hp <= 0 then
|
||||
Hooks.run("gather:depleted", { instance = instance, player = player, values = values })
|
||||
instance:Destroy()
|
||||
end
|
||||
end)
|
||||
end,
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
--[[
|
||||
Components — the creator-facing layer. A component binds engine behavior to a
|
||||
CollectionService tag, reading per-instance Attributes (with defaults). Creators
|
||||
tag their OWN objects and fill in attributes; no engine-side definition required.
|
||||
|
||||
Components.define({
|
||||
name = "Gatherable",
|
||||
tag = "Gatherable",
|
||||
attributes = { ItemId = "unknown", Yield = 1, HP = 3 },
|
||||
onSetup = function(instance, values) ... end,
|
||||
})
|
||||
|
||||
Call Components.scan() once (SurvivorCore.start does this) to bind existing tagged
|
||||
instances and watch for new ones. A future builder UI can set these attributes
|
||||
visually instead of by hand.
|
||||
]]
|
||||
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
|
||||
local Components = {}
|
||||
|
||||
export type Spec = {
|
||||
name: string,
|
||||
tag: string,
|
||||
attributes: { [string]: any }?, -- attribute name -> default value
|
||||
onSetup: ((instance: Instance, values: { [string]: any }) -> ())?,
|
||||
}
|
||||
|
||||
local defined: { [string]: Spec } = {}
|
||||
|
||||
function Components.define(spec: Spec): Spec
|
||||
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
|
||||
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
|
||||
defined[spec.name] = spec
|
||||
return spec
|
||||
end
|
||||
|
||||
local function readAttributes(instance: Instance, attributes: { [string]: any }?)
|
||||
local values = {}
|
||||
for attrName, default in attributes or {} do
|
||||
local v = instance:GetAttribute(attrName)
|
||||
values[attrName] = if v == nil then default else v
|
||||
end
|
||||
return values
|
||||
end
|
||||
|
||||
local function bind(instance: Instance, spec: Spec)
|
||||
if instance:GetAttribute("_scBound") then
|
||||
return
|
||||
end
|
||||
instance:SetAttribute("_scBound", true)
|
||||
if spec.onSetup then
|
||||
spec.onSetup(instance, readAttributes(instance, spec.attributes))
|
||||
end
|
||||
end
|
||||
|
||||
-- Bind everything currently tagged, then keep binding new instances as they appear.
|
||||
function Components.scan()
|
||||
for _, spec in defined do
|
||||
for _, instance in CollectionService:GetTagged(spec.tag) do
|
||||
task.spawn(bind, instance, spec)
|
||||
end
|
||||
CollectionService:GetInstanceAddedSignal(spec.tag):Connect(function(instance)
|
||||
bind(instance, spec)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return Components
|
||||
@@ -0,0 +1,34 @@
|
||||
--[[
|
||||
Assets — typed asset-id lookup. The engine NEVER hardcodes asset ids; games
|
||||
register theirs and the engine reads them back, with a safe fallback.
|
||||
|
||||
Assets.register("Sounds", "Harvest", "rbxassetid://123")
|
||||
Assets.get("Sounds", "Harvest") -- "rbxassetid://123", or "" + warning if missing
|
||||
]]
|
||||
|
||||
local Assets = {}
|
||||
|
||||
local store: { [string]: { [string]: string } } = {}
|
||||
|
||||
function Assets.register(category: string, key: string, assetId: string)
|
||||
store[category] = store[category] or {}
|
||||
store[category][key] = assetId
|
||||
end
|
||||
|
||||
function Assets.registerCategory(category: string, map: { [string]: string })
|
||||
for key, id in map do
|
||||
Assets.register(category, key, id)
|
||||
end
|
||||
end
|
||||
|
||||
function Assets.get(category: string, key: string): string
|
||||
local cat = store[category]
|
||||
local id = cat and cat[key]
|
||||
if not id or id == "" then
|
||||
warn(`[SurvivorCore.Assets] missing asset '{category}.{key}' — using fallback`)
|
||||
return ""
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
return Assets
|
||||
@@ -0,0 +1,58 @@
|
||||
--[[
|
||||
Config — engine ships default tuning per section; games override via deep merge.
|
||||
|
||||
Config.defineSection("Energy", { DrainPerSecond = 16, RegenPerSecond = 14 })
|
||||
Config.override("Energy", { DrainPerSecond = 10 }) -- game tweak
|
||||
Config.get("Energy.DrainPerSecond") -- 10
|
||||
]]
|
||||
|
||||
local Config = {}
|
||||
|
||||
local sections: { [string]: any } = {}
|
||||
|
||||
local function deepCopy(t: any): any
|
||||
if typeof(t) ~= "table" then
|
||||
return t
|
||||
end
|
||||
local c = {}
|
||||
for k, v in t do
|
||||
c[k] = deepCopy(v)
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
local function deepMerge(base: any, over: any)
|
||||
for k, v in over do
|
||||
if typeof(v) == "table" and typeof(base[k]) == "table" then
|
||||
deepMerge(base[k], v)
|
||||
else
|
||||
base[k] = deepCopy(v)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Engine code calls this to declare a section with its default values.
|
||||
function Config.defineSection(name: string, defaults: { [string]: any })
|
||||
assert(sections[name] == nil, `Config section '{name}' already defined`)
|
||||
sections[name] = deepCopy(defaults)
|
||||
end
|
||||
|
||||
-- Game code calls this to override engine defaults.
|
||||
function Config.override(name: string, overrides: { [string]: any })
|
||||
assert(sections[name] ~= nil, `Config section '{name}' not defined`)
|
||||
deepMerge(sections[name], overrides)
|
||||
end
|
||||
|
||||
-- Dotted lookup: Config.get("Energy.DrainPerSecond")
|
||||
function Config.get(path: string): any
|
||||
local node: any = sections
|
||||
for part in string.gmatch(path, "[^.]+") do
|
||||
if typeof(node) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
node = node[part]
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
return Config
|
||||
@@ -0,0 +1,34 @@
|
||||
--[[
|
||||
EventBridge — the engine's event bus. Systems fire semantic events; anything
|
||||
(achievements, quests, analytics, an external webhook) can subscribe with zero
|
||||
changes to the firing code.
|
||||
|
||||
local disconnect = EventBridge.onFire(function(eventType, player, data) ... end)
|
||||
EventBridge.fire("animal_killed", player, { species = "deer" })
|
||||
]]
|
||||
|
||||
local EventBridge = {}
|
||||
|
||||
type Listener = (eventType: string, player: Player?, data: { [string]: any }) -> ()
|
||||
|
||||
local listeners: { Listener } = {}
|
||||
|
||||
function EventBridge.onFire(callback: Listener): () -> ()
|
||||
table.insert(listeners, callback)
|
||||
return function()
|
||||
local i = table.find(listeners, callback)
|
||||
if i then
|
||||
table.remove(listeners, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function EventBridge.fire(eventType: string, player: Player?, data: { [string]: any }?)
|
||||
local payload = data or {}
|
||||
for _, cb in listeners do
|
||||
task.spawn(cb, eventType, player, payload)
|
||||
end
|
||||
-- TODO (extraction): optional HTTP forwarding + batched queue, endpoint from Config.
|
||||
end
|
||||
|
||||
return EventBridge
|
||||
@@ -0,0 +1,42 @@
|
||||
--[[
|
||||
Hooks — lifecycle extension points. Where EventBridge is "something happened",
|
||||
Hooks is "the engine is about to / just did X — creators, do your thing here".
|
||||
|
||||
Hooks.on("craft:start", function(ctx) ctx.station:lightFire() end)
|
||||
Hooks.on("craft:end", function(ctx) ringBell(ctx.station) end)
|
||||
-- inside the engine:
|
||||
Hooks.run("craft:start", { station = station, recipe = recipe, player = player })
|
||||
|
||||
This is how game-specific flourish (tree-felling physics, station VFX, custom drops)
|
||||
stays OUT of the engine while remaining first-class.
|
||||
]]
|
||||
|
||||
local Hooks = {}
|
||||
|
||||
type Hook = (...any) -> ()
|
||||
|
||||
local hooks: { [string]: { Hook } } = {}
|
||||
|
||||
function Hooks.on(name: string, callback: Hook): () -> ()
|
||||
hooks[name] = hooks[name] or {}
|
||||
table.insert(hooks[name], callback)
|
||||
return function()
|
||||
local list = hooks[name]
|
||||
local i = list and table.find(list, callback)
|
||||
if i then
|
||||
table.remove(list, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Hooks.run(name: string, ...)
|
||||
local list = hooks[name]
|
||||
if not list then
|
||||
return
|
||||
end
|
||||
for _, cb in list do
|
||||
task.spawn(cb, ...)
|
||||
end
|
||||
end
|
||||
|
||||
return Hooks
|
||||
@@ -0,0 +1,69 @@
|
||||
--[[
|
||||
Registry — the shared lifecycle behind every content registry: register a def,
|
||||
validate it, index it by a key, and query it. Returns a plain table of functions
|
||||
so the public API is dot-callable: SurvivorCore.Items.register({ ... }).
|
||||
|
||||
local Items = Registry.new("Items", { keyField = "id" })
|
||||
Items.register({ id = "reed", name = "Reed" })
|
||||
Items.get("reed")
|
||||
]]
|
||||
|
||||
local Registry = {}
|
||||
|
||||
export type Options = {
|
||||
keyField: string?, -- unique id field (default "id")
|
||||
validate: ((def: any) -> (boolean, string?))?,
|
||||
}
|
||||
|
||||
function Registry.new(name: string, options: Options?)
|
||||
local opts = options or {}
|
||||
local keyField = opts.keyField or "id"
|
||||
local validate = opts.validate
|
||||
|
||||
local byId: { [any]: any } = {}
|
||||
local all: { any } = {}
|
||||
|
||||
local self = {}
|
||||
self.name = name
|
||||
|
||||
function self.register(def: any): any
|
||||
local key = def[keyField]
|
||||
assert(key ~= nil, `[{name}] def is missing key field '{keyField}'`)
|
||||
assert(byId[key] == nil, `[{name}] '{tostring(key)}' is already registered`)
|
||||
if validate then
|
||||
local ok, err = validate(def)
|
||||
assert(ok, `[{name}] '{tostring(key)}' is invalid: {err or "?"}`)
|
||||
end
|
||||
byId[key] = def
|
||||
table.insert(all, def)
|
||||
return def
|
||||
end
|
||||
|
||||
function self.registerMany(defs: { any })
|
||||
for _, def in defs do
|
||||
self.register(def)
|
||||
end
|
||||
end
|
||||
|
||||
function self.get(key: any): any
|
||||
return byId[key]
|
||||
end
|
||||
|
||||
function self.getAll(): { any }
|
||||
return all
|
||||
end
|
||||
|
||||
function self.query(predicate: (def: any) -> boolean): { any }
|
||||
local out = {}
|
||||
for _, def in all do
|
||||
if predicate(def) then
|
||||
table.insert(out, def)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return Registry
|
||||
@@ -0,0 +1,58 @@
|
||||
--[[
|
||||
SurvivorCore — root module.
|
||||
|
||||
local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
|
||||
SurvivorCore.Items.register({ id = "reed", name = "Reed" })
|
||||
SurvivorCore.start()
|
||||
|
||||
Two extension layers:
|
||||
• Programmatic registries (Items, Recipes, Stats, Mobs, ...) — register content from code.
|
||||
• Creator components (tag your own object "Gatherable", set attributes) + lifecycle Hooks.
|
||||
]]
|
||||
|
||||
local Config = require(script.foundation.Config)
|
||||
local Assets = require(script.foundation.Assets)
|
||||
local EventBridge = require(script.foundation.EventBridge)
|
||||
local Hooks = require(script.foundation.Hooks)
|
||||
local Registries = require(script.registries)
|
||||
local Components = require(script.components)
|
||||
|
||||
local SurvivorCore = {}
|
||||
|
||||
SurvivorCore.VERSION = "0.1.0"
|
||||
|
||||
-- Foundation
|
||||
SurvivorCore.Config = Config
|
||||
SurvivorCore.Assets = Assets
|
||||
SurvivorCore.Events = EventBridge
|
||||
SurvivorCore.Hooks = Hooks
|
||||
|
||||
-- Content registries
|
||||
SurvivorCore.Items = Registries.Items
|
||||
SurvivorCore.Recipes = Registries.Recipes
|
||||
SurvivorCore.Stats = Registries.Stats
|
||||
SurvivorCore.Achievements = Registries.Achievements
|
||||
SurvivorCore.Codex = Registries.Codex
|
||||
SurvivorCore.Appearance = Registries.Appearance
|
||||
SurvivorCore.Mobs = Registries.Mobs
|
||||
|
||||
-- Creator-facing component layer
|
||||
SurvivorCore.Components = Components
|
||||
|
||||
local started = false
|
||||
|
||||
-- Boot the engine. Call once, from the server, after registering content.
|
||||
function SurvivorCore.start(_options: { [string]: any }?)
|
||||
assert(not started, "SurvivorCore.start() called twice")
|
||||
started = true
|
||||
|
||||
-- Load built-in components so their tags are recognised.
|
||||
require(script.components.Gatherable)
|
||||
|
||||
-- TODO (extraction): boot order — Config merge → Assets → persistence → systems.
|
||||
Components.scan()
|
||||
|
||||
return SurvivorCore
|
||||
end
|
||||
|
||||
return SurvivorCore
|
||||
@@ -0,0 +1,31 @@
|
||||
--[[
|
||||
The content registry family. Each is an empty registry the game populates at startup;
|
||||
the engine ships zero concrete items/recipes/lore/ids.
|
||||
|
||||
NOTE: Crafting and cooking are ONE registry — `station` ("hand", "campfire", ...) is
|
||||
just a routing tag, per the boundary-map decision.
|
||||
]]
|
||||
|
||||
local Registry = require(script.Parent.foundation.Registry)
|
||||
|
||||
local Registries = {}
|
||||
|
||||
Registries.Items = Registry.new("Items", { keyField = "id" })
|
||||
Registries.Recipes = Registry.new("Recipes", { keyField = "id" })
|
||||
Registries.Stats = Registry.new("Stats", { keyField = "name" })
|
||||
Registries.Achievements = Registry.new("Achievements", { keyField = "key" })
|
||||
Registries.Codex = Registry.new("Codex", { keyField = "id" })
|
||||
Registries.Appearance = Registry.new("Appearance", { keyField = "id" })
|
||||
Registries.Mobs = Registry.new("Mobs", { keyField = "id" })
|
||||
|
||||
-- Documented alias: Stats.defineStat(model) == Stats.register(model)
|
||||
Registries.Stats.defineStat = Registries.Stats.register
|
||||
|
||||
-- Convenience: all recipes for a given station.
|
||||
function Registries.Recipes.forStation(station: string)
|
||||
return Registries.Recipes.query(function(r)
|
||||
return r.station == station
|
||||
end)
|
||||
end
|
||||
|
||||
return Registries
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "temujincalidius/survivorcore"
|
||||
description = "Batteries-included, creator-extensible survival game framework for Roblox."
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
authors = ["Samuel Lison"]
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = ["demo", "docs", "*.project.json"]
|
||||
|
||||
[dependencies]
|
||||
Reference in New Issue
Block a user