Merge pull request #45 from TemujinCalidius/dev

release: v0.5.0 → main
This commit is contained in:
Samuel Lison
2026-06-25 20:22:52 +10:00
committed by GitHub
21 changed files with 2171 additions and 32 deletions
+45
View File
@@ -5,6 +5,51 @@ All notable changes to SurvivorCore are recorded here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased`
is promoted to the new version and `main` is tagged `vX.Y.Z`.
## 0.5.0 — 2026-06-25
### Added
- **Mob & AI engine** (#17) — the shared creature substrate combat, animals and monsters all build
on. A **mob is a Model tagged `Mob`** with a Humanoid + PrimaryPart, so it's damaged, healed and
killed *exactly* like a player. A reusable **FSM** (idle / wander / chase / attack / flee /
return-on-leash / death) drives behavior, with the profile picked by **data** — a `Mobs` def's
`faction`: `"hostile"` chases + attacks, `"passive"` flees, `"neutral"` wanders. Line-of-sight,
leash distance and target selection are built in; movement is `Humanoid:MoveTo`. New
`SurvivorCore.Mobs` runtime — `spawn` / `adopt` / `damage` / `getActive` / `isMob` (the registry's
`register` / `loadFromFolder` still author defs in code or no-code) — plus per-mob-type
**reactions** `SurvivorCore.Mobs.onReaction(mobType, "spawned"|"hit"|"attack"|"died", …)` for death
fades, spawn cries, etc. A `Mobs` Config section tunes tick rate, default aggro/leash/attack and
respawn. New hooks: `mob:spawned` / `mob:hit` / `mob:attack` / `mob:died`. See
[docs/mobs.md](docs/mobs.md).
- **Combat — melee + ranged** (#12, #14) — server-authoritative combat reusing the v0.4.0
client-input → server-validated-hit pipeline. A **weapon is just an item** (`category = "weapon"` +
a `toolType` so the hotbar equips it) with flat `weapon*` stats. **Melee:** equip + click; the
server picks the nearest valid target (a mob, or another player when `FriendlyFire` is on) within
range + line-of-sight and applies damage. **Ranged (bow):** a TCE-style **aiming** flow — hold
right-click to aim (over-the-shoulder camera + FOV zoom + a crosshair and a charge ring), hold
left-click to **draw**, release to **fire** along the crosshair. The server recomputes the shot from
the bow's muzzle, times the draw (anti-cheat), consumes one arrow from the inventory, and simulates
the **gravity arc** authoritatively, sending the arc path back so the client flies a cosmetic arrow
along the real curve. **Arrows are their own configurable ammo item** (`category = "ammo"`): per
type a **weight**, **damage ×**, **drop/curve ×**, **max range** and **speed ×**, so different
arrows fly and hit differently — a shot combines the bow's pullback with the arrow's ballistics. The
**kill-event schema is designed once** here — `combat:hit` / `combat:kill` `{ attacker, victim,
weapon, source }` — fired through both `Hooks` and `EventBridge`. New `SurvivorCore.Combat` + a
`Combat` Config section (ranges, cooldowns, friendly fire, bow physics). Because mob/player damage
flows through `Humanoid:TakeDamage`, it's lethal and **drives the survival Health HUD with no extra
wiring**. See [docs/combat.md](docs/combat.md).
- **No-code mobs & weapons** (#11, Builder slice) — the admin plugin's **Content** widget gains
**Mobs**, **Weapons** and **Arrows / Ammo** editors (schema-driven, like Items/Gatherables): create
a mob type (faction/health/speed/ranges) — **+ Add to World** drops a tagged placeholder rig; create
a weapon (kind/damage/range/cooldown + bow draw/speed/ammo) — **+ Tool model** drops a starter `Tool`
to build the held look on; create an arrow type (damage/curve/range/speed/weight). The engine loads
all of them from `SurvivorCoreContent` at start — what the plugin writes, the runtime registers, no
code. A creator also authors a mob by tagging any rigged Model **`Mob`** and setting `MobType`.
### Fixed
- Removing or consuming one item no longer clears **unrelated** hotbar pins — the orphaned-pin sweep
is now scoped to the affected item. (Previously, e.g., firing a bow that consumed an arrow could
unpin and unequip a hotbar-pinned weapon that had no inventory stack.)
## 0.4.0 — 2026-06-24
### Added
+173 -3
View File
@@ -93,6 +93,83 @@ SurvivorCore.Items.register({
icon = "rbxassetid://137051934393677",
})
-- Weapons are just items: a `toolType` (so the hotbar equips them) + flat `weapon*` stats the Combat
-- system reads. Equip from the hotbar, then click (melee) or hold-to-draw + release (bow).
SurvivorCore.Items.register({
id = "wood_club",
name = "Wooden Club",
description = "A heavy length of timber. Equip it, then click a creature to swing.",
stack = 1,
weight = 1.8,
category = "weapon",
toolType = "club",
weaponKind = "melee",
weaponDamage = 20,
weaponRange = 8,
weaponCooldown = 0.6,
icon = "rbxassetid://102789813187589",
})
SurvivorCore.Items.register({
id = "short_bow",
name = "Short Bow",
description = "Hold right-click to aim, hold left-click to draw (longer = stronger), release to fire.",
stack = 1,
weight = 1.2,
category = "weapon",
toolType = "bow",
weaponKind = "bow",
weaponDamage = 30,
weaponDrawTime = 1,
weaponProjectileSpeed = 210,
weaponMaxRange = 260,
weaponAmmo = "arrow", -- a balanced, flat-ish arrow (consumed from the inventory per shot)
icon = "rbxassetid://124374881225649",
})
SurvivorCore.Items.register({
id = "war_bow",
name = "War Bow",
description = "A heavier bow — slower pull, harder hit. Loads heavy arrows that arc steeply.",
stack = 1,
weight = 1.8,
category = "weapon",
toolType = "bow",
weaponKind = "bow",
weaponDamage = 42,
weaponDrawTime = 1.4, -- slower to full draw
weaponProjectileSpeed = 180,
weaponMaxRange = 300,
weaponAmmo = "heavy_arrow",
icon = "rbxassetid://124374881225649",
})
-- Two arrow TYPES with different ballistics — exactly what the admin "Arrows / Ammo" editor authors.
SurvivorCore.Items.register({
id = "arrow",
name = "Arrow",
description = "A balanced arrow. Flies fairly flat.",
category = "ammo",
weight = 0.05,
stack = 32,
ammoDamage = 1, -- the bow's base damage
ammoDrop = 1, -- normal gravity / curve
ammoSpeed = 0, -- 0 = use the bow's speed
ammoRange = 0, -- 0 = use the bow's max range
icon = "rbxassetid://86406240258610",
})
SurvivorCore.Items.register({
id = "heavy_arrow",
name = "Heavy Arrow",
description = "Heavier head: hits harder and drops fast (a steep arc), but won't reach far.",
category = "ammo",
weight = 0.12,
stack = 16,
ammoDamage = 1.4, -- +40% damage
ammoDrop = 2, -- doubles gravity → a pronounced arc
ammoSpeed = 0.85, -- a touch slower
ammoRange = 140, -- short effective range
icon = "rbxassetid://86406240258610",
})
-- Gatherable RESOURCE defs: what a tagged node *is*. A creator tags a mesh "Gatherable" and sets
-- Resource = "<id>" to inherit these (no per-node attributes). HP>0 + a tool = click-to-swing;
-- no tool = bare-hand hold-E. (These can also be authored no-code via the admin plugin.)
@@ -112,6 +189,29 @@ SurvivorCore.Resources.register({
yieldMin = 1,
yieldMax = 2,
})
-- Mob defs: what a "Mob"-tagged model *is*. `faction` picks the AI profile (hostile chases + attacks;
-- passive flees). The engine ships zero creatures — these are demo content (also no-code-authorable).
SurvivorCore.Mobs.register({
id = "husk",
faction = "hostile",
health = 60,
walkSpeed = 5,
runSpeed = 15,
aggroRange = 40,
leashRange = 70,
attackRange = 6,
attackDamage = 8,
attackCooldown = 1.5,
})
SurvivorCore.Mobs.register({
id = "boar",
faction = "passive",
health = 40,
walkSpeed = 6,
runSpeed = 24,
aggroRange = 28, -- bolts when you get within ~28 studs
})
SurvivorCore.Items.register({
id = "straw_hat",
name = "Straw Hat",
@@ -162,20 +262,35 @@ end)
local function seedPlayer(player: Player)
player:SetAttribute("Credits", 250)
-- A starter inventory (slots 1-4 of the base 5; slot 5 left free to demo equip/unequip).
-- A starter inventory. The satchel is equipped (EquipSlot_Back) for the extra slots its backpack
-- bonus grants, leaving room for the gather tool, weapons and arrows.
player:SetAttribute("InvSlot_1", "berry")
player:SetAttribute("InvQty_1", 12)
player:SetAttribute("InvSlot_2", "mushroom")
player:SetAttribute("InvQty_2", 3)
player:SetAttribute("InvSlot_3", "water_skin")
player:SetAttribute("InvQty_3", 1)
player:SetAttribute("InvSlot_4", "reed_satchel")
player:SetAttribute("InvQty_4", 1)
player:SetAttribute("InvSlot_4", "arrow")
player:SetAttribute("InvQty_4", 24)
player:SetAttribute("InvSlot_5", "stone_axe")
player:SetAttribute("InvQty_5", 1)
-- Weapons live in real inventory slots (so a hotbar pin always has a backing stack); the satchel's
-- +6 slots make room.
player:SetAttribute("InvSlot_6", "wood_club")
player:SetAttribute("InvQty_6", 1)
player:SetAttribute("InvSlot_7", "short_bow")
player:SetAttribute("InvQty_7", 1)
player:SetAttribute("InvSlot_8", "war_bow")
player:SetAttribute("InvQty_8", 1)
player:SetAttribute("InvSlot_9", "heavy_arrow")
player:SetAttribute("InvQty_9", 12)
player:SetAttribute("HotbarSlot1", "berry") -- a pre-pinned quick slot
player:SetAttribute("HotbarSlot2", "stone_axe") -- press 2 to equip the axe, then click a tree
player:SetAttribute("HotbarSlot3", "wood_club") -- press 3 to equip the club, then click a mob
player:SetAttribute("HotbarSlot4", "short_bow") -- press 4: aim (RMB) + draw (LMB) — balanced arrows
player:SetAttribute("HotbarSlot5", "war_bow") -- press 5: the war bow — heavy arrows that arc steeply
player:SetAttribute("EquipSlot_Head", "straw_hat") -- a pre-filled equipment slot
player:SetAttribute("EquipSlot_Back", "reed_satchel") -- equipped for +slots / +carry weight
end
for _, player in Players:GetPlayers() do
seedPlayer(player)
@@ -307,4 +422,59 @@ SurvivorCore.Gather.onReaction("oak_tree", "depleted", function(ctx)
end)
end)
-- 5. The demo "combat field": weapon Tool looks the hotbar equips, a hostile husk that chases +
-- attacks, and a passive boar that bolts when you near it. All demo content — the engine ships no
-- creatures or weapons. (Mobs.spawn is available after start(); the AI runs server-side.)
local function buildWeaponTemplates()
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
local tools = content and content:FindFirstChild("Tools")
if not tools then
return
end
local function template(id: string, size: Vector3, color: Color3)
if tools:FindFirstChild(id) then
return
end
local tool = Instance.new("Tool")
tool.Name = id
tool.RequiresHandle = true
tool.CanBeDropped = false
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = size
handle.Color = color
handle.Material = Enum.Material.Wood
handle.CanCollide = false
handle.Massless = true
handle.Parent = tool
tool.Parent = tools
end
template("wood_club", Vector3.new(0.5, 3.5, 0.5), Color3.fromRGB(120, 85, 55))
template("short_bow", Vector3.new(0.3, 4, 0.3), Color3.fromRGB(150, 110, 70))
template("war_bow", Vector3.new(0.35, 5, 0.35), Color3.fromRGB(110, 80, 60))
end
buildWeaponTemplates()
SurvivorCore.Mobs.spawn("husk", CFrame.new(40, 5, 20), { respawn = true })
SurvivorCore.Mobs.spawn("husk", CFrame.new(48, 5, 32), { respawn = true })
SurvivorCore.Mobs.spawn("boar", CFrame.new(-30, 5, 18))
-- Death juice (per mob type): fade the husk's body out over its corpse time. Pure creator content
-- via the reaction API — the engine just fires "died"; the game decides what a corpse looks like.
local FADE = TweenInfo.new(4, Enum.EasingStyle.Linear)
SurvivorCore.Mobs.onReaction("husk", "died", function(ctx)
local model = ctx.instance
if not model then
return
end
for _, part in model:GetDescendants() do
if part:IsA("BasePart") then
TweenService:Create(part, FADE, { Transparency = 1 }):Play()
end
end
end)
SurvivorCore.Hooks.on("mob:died", function(ctx)
print(("[demo] %s was slain"):format(tostring(ctx.mobType)))
end)
print("SurvivorCore demo booted — v" .. SurvivorCore.VERSION)
+6 -5
View File
@@ -5,15 +5,16 @@ Attributes in the Explorer. It adds two toolbar buttons under **SurvivorCore**:
- **Survival Stats** — tune the survival-stat rates/thresholds/HUD on the `SurvivalStatsConfig`
instance (the deltas-only, locked model below).
- **Content** — create/edit/delete **items** and **gatherable resources** with no code (the Builder
first slice). It writes `SurvivorCoreContent` instances the engine loads at `start()` — see
[content-authoring.md](content-authoring.md). Unlike the stats editor, content is full
owner-authored defs (not deltas). Every edit is one Studio undo step.
- **Content** — create/edit/delete **items**, **weapons**, **gatherable resources** and **mobs** with
no code (the Builder slice). It writes `SurvivorCoreContent` instances the engine loads at `start()`
— see [content-authoring.md](content-authoring.md). Gatherables and mobs each get a **+ Add to
World** button that drops the tagged instance in front of the camera. Unlike the stats editor,
content is full owner-authored defs (not deltas). Every edit is one Studio undo step.
The rest of this page covers the Survival Stats editor; both install the same way. It's the
[Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
> 📹 **Demos:** [HUD, survival stats & the admin plugin](https://makertube.net/w/xqX7wfRpTqd9L9BkozCS1P) · [no-code item & gatherable creation](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE)
> 📹 **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)
## Install
+135
View File
@@ -0,0 +1,135 @@
# Combat (melee + ranged)
> 📹 **Demo:** [melee hits, bows & arrows, and creating weapons/ammo/mobs in the admin plugin](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU)
Combat ([`src/systems/Combat.luau`](../src/systems/Combat.luau)) is **server-authoritative** and
reuses the v0.4.0 **client-input → RemoteEvent → server-validated-hit** pipeline (the same shape as
[harvesting](harvesting.md)). The client only *requests* an attack; the server validates everything —
equipped weapon, range, line-of-sight, cooldown — and decides the hit. It never trusts the client.
Targets are anything with a `Humanoid`: engine [mobs](mobs.md) (always damageable) and, when
`Config "Combat".FriendlyFire` is on, other players. Damage flows through `Humanoid:TakeDamage`, so a
player victim's **survival Health HUD updates with no extra wiring** and death/respawn is clean.
## Weapons are items
A weapon is just an **`Items` def** with `category = "weapon"`, a `toolType` (so the hotbar→Tool
bridge equips it as a real `Tool`), and flat `weapon*` fields. Flat attributes keep weapons fully
[no-code-authorable](content-authoring.md) (the admin plugin's **Weapons** editor writes exactly
these):
```lua
SurvivorCore.Items.register({
id = "wood_club",
name = "Wooden Club",
category = "weapon",
toolType = "club", -- lets the hotbar equip it
weaponKind = "melee", -- "melee" | "bow"
weaponDamage = 20,
weaponRange = 8, -- melee reach (server-validated)
weaponCooldown = 0.6,
})
SurvivorCore.Items.register({
id = "short_bow",
name = "Short Bow",
category = "weapon",
toolType = "bow",
weaponKind = "bow",
weaponDamage = 34, -- base; multiplied by the arrow's ammoDamage and the draw
weaponDrawTime = 1, -- seconds to a full-power draw (the bow's "pullback")
weaponProjectileSpeed = 200,
weaponMaxRange = 260, -- studs (fallback if the arrow sets no ammoRange)
weaponAmmo = "arrow", -- the arrow id consumed per shot ("" = no ammo)
})
```
## Melee
Equip a melee weapon from the hotbar and **click**. The client
([`CombatInput`](../src/client/CombatInput.luau)) asks the server to swing; the server finds the
nearest valid target within `weaponRange` (falling back to `Combat.MeleeRange`) with line-of-sight
and applies `weaponDamage`. A registered swing animation (`Assets "WeaponAnims"` by `toolType`) plays
client-side.
## Ranged (bow) — the aiming flow
Bows use a TCE-style two-button aim ([`CombatInput`](../src/client/CombatInput.luau)):
1. **Hold right-click to aim** — the camera shifts over-the-shoulder (`Humanoid.CameraOffset`) and
zooms (FOV), and a **crosshair + charge ring** appear.
2. **Hold left-click to draw** — the ring fills and greens up over `weaponDrawTime`.
3. **Release left-click to fire** along the crosshair; **release right-click** exits aim.
The client sends the **world point under the crosshair** (a camera-centre ray); the server recomputes
the true direction from the bow's own muzzle (killing shoulder-camera parallax and any spoofed
direction), times the draw itself (anti-cheat), consumes one `weaponAmmo` arrow from the inventory
(**no arrows → no shot**), and **simulates the arrow authoritatively** — a gravity arc stepped as
short raycasts. It sends back the arc's **path**, and the client flies a cosmetic arrow along that
exact curve. The melee and bow paths route by the equipped Tool's `WeaponKind`, so they coexist with
harvesting without ever double-firing.
## Arrows — configurable, weighted ammo
An **arrow is its own item** (`category = "ammo"`), authored in code or via the admin **Arrows / Ammo**
editor — so a game can ship many arrow types with different ballistics. A shot **combines the bow's
pullback with the arrow's stats** and the draw strength:
| Arrow field | Effect |
|---|---|
| `ammoDamage` | × the bow's `weaponDamage` (e.g. `1.4` = +40%) |
| `ammoDrop` | × gravity — **the curve**; heavier arrows (`> 1`) arc steeply and fall short |
| `ammoRange` | caps the flight in studs (`0` = use the bow's `weaponMaxRange`) |
| `ammoSpeed` | × the bow's projectile speed (`0` = use the bow's speed) |
| `weight` | carry weight per arrow (the inventory cost of ammo) |
```lua
SurvivorCore.Items.register({
id = "heavy_arrow", name = "Heavy Arrow", category = "ammo",
weight = 0.12, stack = 16,
ammoDamage = 1.4, -- hits harder
ammoDrop = 2, -- doubles gravity → a pronounced arc
ammoSpeed = 0.85, -- a touch slower
ammoRange = 140, -- short effective range
})
```
Final shot = `damage = weaponDamage × ammoDamage × draw`, `speed = (bow speed × ammoSpeed) × draw`,
`gravity = Combat.Bow.Gravity × ammoDrop`, `range = ammoRange or weaponMaxRange or Combat.Bow.MaxRange`.
Draw strength scales from `Combat.Bow.MinDrawDamageMult` (no draw) to full (full draw). Point a bow's
`weaponAmmo` at any arrow id to set its ammo.
## The kill-event schema (designed once)
Both paths fire the same schema, through **`Hooks`** (engine extension) **and `EventBridge`**
(analytics / quests / achievements):
| Event | Payload |
|---|---|
| `combat:hit` | `{ attacker, victim, weapon, source, damage, victimHpLeft }` |
| `combat:kill` | `{ attacker, victim, weapon, source }``source` = `"melee"` \| `"bow"` |
This replaces the scattered, content-specific kill events a survival game tends to grow
(`zombie_killed` / `animal_killed` / `pvp_kill` / `bow_kill` / …) with one schema every consumer
subscribes to. A mob's own death also fires the lifecycle `mob:died` (see [mobs.md](mobs.md)) — the
two are complementary: `combat:kill` is attacker-attributed, `mob:died` is the creature lifecycle.
```lua
SurvivorCore.Hooks.on("combat:kill", function(ctx)
if ctx.source == "bow" then awardArcheryXp(ctx.attacker) end
end)
```
## Runtime & tuning
`SurvivorCore.Combat` boots with the engine (after Inventory + Mobs). Tuning:
```lua
Config.override("Combat", {
MeleeRange = 8, MeleeCooldown = 0.6, RequireLineOfSight = true, FriendlyFire = false,
Bow = { Gravity = 80, ProjectileSpeed = 180, MaxRange = 300, MinDrawDamageMult = 0.3, StepSize = 4 },
})
```
Per-weapon `weapon*` values override these fallbacks. **Out of scope:** durability, blocking/parrying,
and AoE are creator content via the hooks above; mob AI lives in [mobs.md](mobs.md).
+37 -4
View File
@@ -1,6 +1,6 @@
# No-code content authoring
> 📹 **Demo:** [creating an item + gatherable in the admin panel](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE)
> 📹 **Demos:** [creating mobs, weapons & ammo (with full stats)](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU) · [creating an item + gatherable](https://makertube.net/w/mCneurjoY3Av6yi48VsGQE)
The engine ships **zero** items, resources, or recipes — your game supplies them. You can do this
two ways, and they coexist:
@@ -23,6 +23,18 @@ ReplicatedStorage
│ • stack = 20
│ • weight = 0.05
│ • category = "consumable"
├─ Weapons (Folder) ← items with category="weapon" (loaded into the Items registry)
│ └─ wood_club (Configuration)
│ • category = "weapon"
│ • toolType = "club" (lets the hotbar equip it)
│ • weaponKind = "melee" ("melee" | "bow")
│ • weaponDamage = 20
├─ Arrows (Folder) ← items with category="ammo" (loaded into the Items registry)
│ └─ heavy_arrow (Configuration)
│ • category = "ammo"
│ • ammoDamage = 1.4 (× the bow's damage)
│ • ammoDrop = 2 (× gravity = the curve)
│ • ammoRange = 140
├─ Resources (Folder)
│ └─ berry_bush (Configuration)
│ • item = "berry"
@@ -30,7 +42,13 @@ ReplicatedStorage
│ • requireTool = "" (blank = bare-hand)
│ • yieldMin = 1
│ • yieldMax = 3
Tools (Folder) ← actual Tool templates the hotbar equips (named by item id)
Mobs (Folder)
│ └─ husk (Configuration)
│ • faction = "hostile" ("hostile" | "passive" | "neutral")
│ • health = 60
│ • aggroRange = 40
├─ Tools (Folder) ← Tool templates the hotbar equips (named by item id)
└─ MobModels (Folder) ← rigged mob templates Mobs.spawn clones (named by mob id)
```
Each child's **Name is the id**; its **attributes are the def fields**
@@ -39,12 +57,23 @@ same instance-config pattern the survival stats use.
## The admin plugin Content widget
Open Studio → the **SurvivorCore** toolbar → **Content**. Two builders:
Open Studio → the **SurvivorCore** toolbar → **Content**. Five builders:
- **Items** — create an item by id, then set Name / Max stack / Weight / Category / Tool type /
Icon / Description.
- **Weapons** — create a weapon by id, then set Kind (melee/bow) / Damage / Range / Cooldown, plus
bow Draw time / Arrow speed / Max range / Ammo item. (Weapons are items with `category = "weapon"`;
they live in their own folder so this editor never collides with the Items editor.) **+ Tool model**
drops a starter `Tool` (a Handle carrying the weapon's `ToolType`/`WeaponKind`) into the world so you
can build the held look on it; move the finished Tool under `SurvivorCoreContent.Tools` (named by the
weapon id) and the hotbar clones it when the weapon is equipped.
- **Arrows / Ammo** — create an arrow type by id, then set Damage × / Drop (curve) × / Max range /
Speed × / Carry weight. A bow's *Ammo item* points at one of these. (Arrows are items with
`category = "ammo"`, in their own folder — see [combat.md](combat.md).)
- **Gatherables** — create a resource by id, then set Yields item / HP (gathers) / Tool required /
Yield min / Yield max.
Yield min / Yield max. **+ Add to World** drops a tagged `Gatherable` node.
- **Mobs** — create a mob type by id, then set Faction / Health / Speeds / Aggro / Leash / Attack.
**+ Add to World** drops a tagged `Mob` placeholder rig (swap in your own model later).
Each edit is one Studio **undo** step. Behind the scenes it creates/edits the
`SurvivorCoreContent` instances above, so pressing Play registers your content with no code.
@@ -56,6 +85,10 @@ attribute to a resource id (e.g. `"berry_bush"`). The node inherits item/HP/tool
See [harvesting.md](harvesting.md) for the full attribute list and the per-type **reaction** hooks
(shake / fell / etc.).
For creatures, tag a rigged Model (Humanoid + PrimaryPart) **`Mob`** and set `MobType` to a mob id
(e.g. `"husk"`); it inherits faction/health/speed/ranges from the def. See [mobs.md](mobs.md) and
[combat.md](combat.md).
## Tools
For a tool item, set `toolType` (e.g. `"axe"`) and `category = "tool"`. To give it a custom look,
+106
View File
@@ -0,0 +1,106 @@
# Mobs & AI
> 📹 **Demo:** [creating mobs & fleeing hunt-NPCs, plus combat and the admin plugin](https://makertube.net/w/tyn8JEMG3CaMbTXid8osdU)
The **mob & AI engine** ([`src/systems/Mobs.luau`](../src/systems/Mobs.luau)) is the shared,
content-free creature substrate that combat (#12), animals (#13) and monsters (#14) all build on.
The key idea: **a mob is a Humanoid.** Concretely, a mob is a `Model` tagged **`Mob`** that contains
a `Humanoid` and a `PrimaryPart` (its `HumanoidRootPart`). Because of that, a mob is damaged, healed
and killed *exactly* like a player — `Humanoid:TakeDamage`, `Humanoid.Health`, `Humanoid.Died` — so
[combat](combat.md) has **one** code path for players and mobs, and there are no bespoke "mob HP"
systems to keep in sync.
> The engine ships **zero** creatures. You supply the rigged model (or let the engine build a blocky
> placeholder), the `Mobs` def (stats), and the death/spawn juice via reactions.
## Defining a mob type
A mob's stats come from a **`Mobs` registry def** — in code or [no-code via the admin
plugin](content-authoring.md):
```lua
SurvivorCore.Mobs.register({
id = "husk",
faction = "hostile", -- "hostile" | "passive" | "neutral" (picks the AI profile)
health = 60,
walkSpeed = 5, -- idle/wander speed
runSpeed = 15, -- chase / flee speed
aggroRange = 40, -- how close a player must be to be noticed (hostile) or fled from (passive)
leashRange = 70, -- studs from spawn before a chaser gives up and returns
attackRange = 6, -- melee reach of the mob's own attack (hostile)
attackDamage = 8,
attackCooldown = 1.5,
})
```
Anything you leave out falls back to the **`Mobs` Config** defaults (see Tuning). Every field also
has a Config default, so a minimal def is just `{ id, faction }`.
## The behavior profile is data
The FSM is one state machine; `faction` selects how it behaves — you don't subclass or script it:
| Faction | Behavior |
|---|---|
| `hostile` | idle/wander near spawn → **chase** a player in `aggroRange` (with line-of-sight) → **attack** within `attackRange`**return** to spawn if it's dragged past `leashRange`. |
| `passive` | idle/wander → **flee** from a player who comes within `aggroRange` (or who strikes it). |
| `neutral` | idle/wander only; **flees briefly** when struck. |
## Placing a mob in the world
Two ways, and they coexist:
**1. Tag a model (no-code).** Build/import any rigged Model with a Humanoid + PrimaryPart, tag it
**`Mob`** (CollectionService), and set one attribute:
| Attribute | Meaning |
|---|---|
| `MobType` | the `Mobs` def id to inherit from (e.g. `"husk"`); blank = the model's `Name` |
Per-instance attributes (`Faction`, `Health`, `WalkSpeed`, `RunSpeed`, `AggroRange`, `LeashRange`,
`AttackRange`, `AttackDamage`, `AttackCooldown`, `WanderRadius`) **override** the def for that one
mob. (The admin plugin's **Mobs** editor writes the def and its **+ Add to World** button drops a
tagged placeholder rig for you.)
**2. Spawn from code** (`SurvivorCore.Mobs`, available after `start()`):
| Function | Behavior |
|---|---|
| `spawn(mobType, cframe, opts?) -> Model` | clone a creator template (`SurvivorCoreContent.MobModels.<mobType>`) or build a placeholder, tag + adopt it. `opts.respawn = true` re-spawns it on death after `opts.respawnSeconds` (default Config). |
| `adopt(model)` | bring an existing tagged Model under AI control (idempotent). |
| `damage(model, amount, source?)` | apply damage to a mob (combat uses this; records the attacker + fires `mob:hit`). |
| `getActive() -> { Model }` · `isMob(model) -> bool` | the live roster / a membership test. |
```lua
SurvivorCore.Mobs.spawn("husk", CFrame.new(40, 5, 20), { respawn = true })
```
## Reactions (the juice)
Global `Hooks.on("mob:died", …)` fire for every mob. For behavior tied to **one** mob type, use the
reaction API — no core edits:
```lua
SurvivorCore.Mobs.onReaction("husk", "died", function(ctx)
-- ctx = { instance, mobType, killer?, position? }
fadeCorpse(ctx.instance) -- creator content; the engine just removes the body after CorpseSeconds
end)
```
Events: `"spawned"` · `"hit"` · `"attack"` (the mob hit a player) · `"died"`. Anims/sounds are
content-free — register them in `Assets` under `MobAnims` / `MobSounds`, keyed `mobType.."_"..state`
(e.g. `husk_attack`); the FSM plays them if present.
## Tuning
`Config.override("Mobs", { TickRate = 0.2, DefaultAggroRange = 40, DefaultLeashRange = 60,
DefaultAttackRange = 6, DefaultAttackDamage = 8, DefaultAttackCooldown = 1.5, RequireLineOfSight =
true, RespawnSeconds = 30, CorpseSeconds = 5 })` — these are the fallbacks a def (or per-mob
attribute) overrides.
## Out of scope (for now)
Pathfinding (mobs walk straight via `Humanoid:MoveTo` — open terrain), butchering carcasses into
yields (#13), and spawn-zone scattering (#16) are follow-ups that build on this substrate. Combat
(#12) is its first consumer — see [combat.md](combat.md).
+111 -1
View File
@@ -43,6 +43,94 @@ ContentAdmin.CATEGORIES = {
{ attr = "description", kind = "string", label = "Description", default = "" },
},
},
Weapons = {
-- Weapons ARE items (category = "weapon"), but live in their own SurvivorCoreContent.Weapons
-- folder so this editor never collides with the Items editor; the engine loads it into the
-- Items registry at start(). `toolType` lets the hotbar equip it; the `weapon*` fields are the
-- flat stats the Combat system reads.
folder = "Weapons",
title = "Weapons",
keyLabel = "New weapon id",
fields = {
{ attr = "name", kind = "string", label = "Name", default = "" },
{ attr = "category", kind = "string", label = "Category", default = "weapon" },
{
attr = "toolType",
kind = "string",
label = "Tool type",
default = "",
placeholder = "sword / bow (required to equip)",
},
{ attr = "weaponKind", kind = "string", label = "Kind", default = "melee", placeholder = "melee / bow" },
{ attr = "weaponDamage", kind = "number", label = "Damage", default = 10 },
{ attr = "weaponRange", kind = "number", label = "Range (melee)", default = 8 },
{ attr = "weaponCooldown", kind = "number", label = "Cooldown", default = 0.6 },
{ attr = "weaponDrawTime", kind = "number", label = "Draw time (bow)", default = 1 },
{ attr = "weaponProjectileSpeed", kind = "number", label = "Arrow speed (bow)", default = 180 },
{
attr = "weaponMaxRange",
kind = "number",
label = "Max range (bow)",
default = 0,
placeholder = "0 = engine default",
},
{
attr = "weaponAmmo",
kind = "string",
label = "Ammo item (bow)",
default = "",
placeholder = "an arrow id; blank = none",
},
{ attr = "stack", kind = "number", label = "Max stack", default = 1 },
{ attr = "weight", kind = "number", label = "Weight", default = 1 },
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
},
},
Arrows = {
-- Arrows ARE items (category = "ammo"), in their own SurvivorCoreContent.Arrows folder (loaded
-- into the Items registry). A bow's `weaponAmmo` points at one of these ids; the shot combines
-- the bow's pullback with the arrow's weight/damage/range. Different arrow types = different
-- ballistics.
folder = "Arrows",
title = "Arrows / Ammo",
keyLabel = "New arrow id",
fields = {
{ attr = "name", kind = "string", label = "Name", default = "" },
{ attr = "category", kind = "string", label = "Category", default = "ammo" },
{
attr = "ammoDamage",
kind = "number",
label = "Damage ×",
default = 1,
placeholder = "1.0 = bow base; 1.2 = +20%",
},
{
attr = "ammoDrop",
kind = "number",
label = "Drop / curve ×",
default = 1,
placeholder = "1.0 = normal; heavier = more arc",
},
{
attr = "ammoRange",
kind = "number",
label = "Max range",
default = 0,
placeholder = "studs; 0 = bow default",
},
{
attr = "ammoSpeed",
kind = "number",
label = "Speed ×",
default = 0,
placeholder = "0 = bow speed; 1.2 = faster",
},
{ attr = "weight", kind = "number", label = "Carry weight", default = 0.05 },
{ attr = "stack", kind = "number", label = "Max stack", default = 32 },
{ attr = "icon", kind = "string", label = "Icon", default = "", placeholder = "rbxassetid://…" },
{ attr = "description", kind = "string", label = "Description", default = "" },
},
},
Resources = {
folder = "Resources",
title = "Gatherables",
@@ -61,10 +149,32 @@ ContentAdmin.CATEGORIES = {
{ attr = "yieldMax", kind = "number", label = "Yield max", default = 1 },
},
},
Mobs = {
folder = "Mobs",
title = "Mobs",
keyLabel = "New mob id",
fields = {
{
attr = "faction",
kind = "string",
label = "Faction",
default = "hostile",
placeholder = "hostile / passive / neutral",
},
{ attr = "health", kind = "number", label = "Health", default = 50 },
{ attr = "walkSpeed", kind = "number", label = "Walk speed", default = 6 },
{ attr = "runSpeed", kind = "number", label = "Run speed", default = 14 },
{ attr = "aggroRange", kind = "number", label = "Aggro range", default = 40 },
{ attr = "leashRange", kind = "number", label = "Leash range", default = 60 },
{ attr = "attackRange", kind = "number", label = "Attack range", default = 6 },
{ attr = "attackDamage", kind = "number", label = "Attack damage", default = 8 },
{ attr = "attackCooldown", kind = "number", label = "Attack cooldown", default = 1.5 },
},
},
} :: { [string]: Category }
-- Render order for the UI.
ContentAdmin.ORDER = { "Items", "Resources" }
ContentAdmin.ORDER = { "Items", "Weapons", "Arrows", "Resources", "Mobs" }
-- ids are lowercase alphanumeric + underscore (matches how content is referenced everywhere).
function ContentAdmin.sanitizeId(raw: any): string
+6 -5
View File
@@ -110,16 +110,17 @@ local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySe
applyDelete(catKey, id)
end)
-- Gatherables get a one-click "Add to World" that drops a tagged, Resource-set Part in front of
-- the camera (the no-code way to place a node) — the bridge between the editor and the world.
-- Gatherables + Mobs drop a tagged, def-linked instance; Weapons drop a starter Tool (Handle +
-- the weapon's ToolType/WeaponKind) you can model the look on — all in front of the camera, the
-- bridge between the editor and the world.
local labelRight = 64
if catKey == "Resources" and applySpawn then
if (catKey == "Resources" or catKey == "Mobs" or catKey == "Weapons") and applySpawn then
local add = make("TextButton", {
Size = UDim2.fromOffset(104, 20),
Position = UDim2.new(1, -168, 0.5, -10),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = "+ Add to World",
Text = if catKey == "Weapons" then "+ Tool model" else "+ Add to World",
TextColor3 = COL_ACCENT,
TextSize = 11,
Font = FONT,
@@ -127,7 +128,7 @@ local function buildEntry(catKey: string, id: string, ContentAdmin: any, applySe
}, { corner(4) })
add.Parent = title
add.MouseButton1Click:Connect(function()
applySpawn(id)
applySpawn(catKey, id)
end)
labelRight = 176
end
+78 -7
View File
@@ -30,7 +30,7 @@ local COL_TEXT = Color3.fromRGB(235, 238, 245)
local TAB_H = 30
local toolbar = plugin:CreateToolbar("SurvivorCore")
local button = toolbar:CreateButton("Admin Panel", "Tune stats + create items/gatherables (no-code)", "")
local button = toolbar:CreateButton("Admin Panel", "Tune stats + create items/weapons/gatherables/mobs (no-code)", "")
button.ClickableWhenViewportHidden = true
-- CreateDockWidgetPluginGui is flagged deprecated by the tooling, but it is still the only API for
@@ -140,16 +140,87 @@ local function applyDelete(catKey: string, id: string): any
end)
end
-- "Add to World": drop a tagged, Resource-set Part in front of the camera, then select it. The
-- no-code way to place a gatherable node — tag + Resource attribute is exactly what the engine reads.
local function applySpawn(id: string): any
return record(`Content: add gatherable '{id}' to world`, function()
local function spawnPosition(): Vector3
local cam = Workspace.CurrentCamera
return if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
end
-- A minimal placeholder mob rig (Model + HumanoidRootPart + Humanoid + head), tagged "Mob" with the
-- MobType set — exactly what the engine adopts at play. The creator swaps in their own rig later.
local function buildMobRig(id: string): Model
local model = Instance.new("Model")
model.Name = id
local rootPart = Instance.new("Part")
rootPart.Name = "HumanoidRootPart"
rootPart.Size = Vector3.new(2, 3, 1)
rootPart.Color = Color3.fromRGB(150, 60, 60)
rootPart.Anchored = false
rootPart.Parent = model
local head = Instance.new("Part")
head.Name = "Head"
head.Shape = Enum.PartType.Ball
head.Size = Vector3.new(1.4, 1.4, 1.4)
head.Color = rootPart.Color
head.CanCollide = false
head.Massless = true
head.CFrame = rootPart.CFrame * CFrame.new(0, 2, 0)
head.Parent = model
local weld = Instance.new("WeldConstraint")
weld.Part0 = rootPart
weld.Part1 = head
weld.Parent = rootPart
local hum = Instance.new("Humanoid")
hum.Parent = model
model.PrimaryPart = rootPart
return model
end
-- "Add to World": drop a tagged, def-linked instance in front of the camera, then select it. The
-- no-code way to place one — tag + def-id attribute is exactly what the engine reads at play.
-- Resources → a Part tagged "Gatherable" with Resource = id.
-- Mobs → a placeholder rig Model tagged "Mob" with MobType = id.
local function applySpawn(catKey: string, id: string): any
return record(`Content: add {catKey} '{id}' to world`, function()
if catKey == "Mobs" then
local model = buildMobRig(id)
model:SetAttribute("MobType", id)
model:PivotTo(CFrame.new(spawnPosition()))
CollectionService:AddTag(model, "Mob")
model.Parent = Workspace
Selection:Set({ model })
return model
end
if catKey == "Weapons" then
-- Drop a starter Tool you can model the look on. It carries the weapon's ToolType /
-- WeaponKind so it's recognisable; move it under ReplicatedStorage.SurvivorCoreContent.Tools
-- (named by this id) to make it the equipped look the hotbar clones.
local node = ContentAdmin.getNode("Weapons", id)
local tool = Instance.new("Tool")
tool.Name = id
tool.RequiresHandle = true
tool.CanBeDropped = false
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = Vector3.new(0.5, 3, 0.5)
handle.Color = Color3.fromRGB(120, 90, 60)
handle.Anchored = true
handle.Position = spawnPosition()
handle.Parent = tool
for _, attr in { "toolType", "weaponKind" } do
local v = node and node:GetAttribute(attr)
if typeof(v) == "string" and v ~= "" then
tool:SetAttribute(attr == "toolType" and "ToolType" or "WeaponKind", v)
end
end
tool.Parent = Workspace
Selection:Set({ tool })
return tool
end
local part = Instance.new("Part")
part.Name = id
part.Size = Vector3.new(4, 4, 4)
part.Anchored = true
local cam = Workspace.CurrentCamera
part.Position = if cam then cam.CFrame.Position + cam.CFrame.LookVector * 16 else Vector3.new(0, 5, 0)
part.Position = spawnPosition()
part:SetAttribute("Resource", id)
CollectionService:AddTag(part, "Gatherable")
part.Parent = Workspace
+424
View File
@@ -0,0 +1,424 @@
--!nonstrict
--[[
CombatInput client. The input half of combat (#12). CLIENT-ONLY.
Routes by the EQUIPPED weapon's `WeaponKind` attribute (stamped on the held Tool by the ToolEquip
bridge), so it coexists with ToolHarvest — a given item is a harvest tool OR a weapon, never both:
• melee → left-click asks the server to swing (CombatSwing); the server picks + validates target.
• bow → a TCE-style aiming flow: HOLD right-click to AIM (over-the-shoulder camera + FOV zoom +
a crosshair and a charge ring); HOLD left-click to DRAW (the ring fills/greens up); RELEASE to
FIRE along the crosshair (camera-centre ray → a world target point). The server simulates the
arrow's gravity arc authoritatively and sends back the arc PATH; this only renders the cosmetic
arrow flying along that exact curve. Arrows must be in the inventory (the server consumes one).
Never authoritative the server re-validates and decides every hit. Booted by startClient().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local SoundService = game:GetService("SoundService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")
assert(RunService:IsClient(), "SurvivorCore.CombatInput is client-only — boot it via SurvivorCore.startClient()")
local Assets = require(script.Parent.Parent.foundation.Assets)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local CombatInput = {}
local started = false
local player = Players.LocalPlayer
-- Aim/draw state (bow only).
local aiming = false
local drawing = false
local drawT0 = 0
local drawTime = 1
local drawTool: Tool? = nil
local savedFov: number? = nil
-- The currently held engine weapon Tool (or nil). A weapon carries a "WeaponKind" attribute.
local function equippedWeapon(): (Tool?, string?)
local char = player.Character
local tool = char and char:FindFirstChildOfClass("Tool")
if not tool or not tool:IsA("Tool") then
return nil, nil
end
local kind = tool:GetAttribute("WeaponKind")
if typeof(kind) ~= "string" or kind == "" then
return nil, nil
end
return tool, kind
end
local function humanoid(): Humanoid?
local char = player.Character
return char and char:FindFirstChildOfClass("Humanoid")
end
-- ===== Aim reticle UI (crosshair + charge ring) ==============================
local aimGui: ScreenGui? = nil
local function ensureAimGui(): ScreenGui?
if aimGui and aimGui.Parent then
return aimGui
end
local playerGui = player:FindFirstChildOfClass("PlayerGui")
if not playerGui then
return nil
end
local gui = Instance.new("ScreenGui")
gui.Name = "CombatAimGui"
gui.ResetOnSpawn = false
gui.IgnoreGuiInset = true
gui.DisplayOrder = 50
gui.Enabled = false
local ring = Instance.new("Frame")
ring.Name = "Ring"
ring.AnchorPoint = Vector2.new(0.5, 0.5)
ring.Position = UDim2.fromScale(0.5, 0.5)
ring.Size = UDim2.fromOffset(22, 22)
ring.BackgroundTransparency = 1
Instance.new("UICorner", ring).CornerRadius = UDim.new(1, 0)
local stroke = Instance.new("UIStroke")
stroke.Thickness = 2.5
stroke.Color = Color3.fromRGB(255, 200, 100)
stroke.Transparency = 0.1
stroke.Parent = ring
ring.Parent = gui
local dot = Instance.new("Frame")
dot.Name = "Dot"
dot.AnchorPoint = Vector2.new(0.5, 0.5)
dot.Position = UDim2.fromScale(0.5, 0.5)
dot.Size = UDim2.fromOffset(4, 4)
dot.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
dot.BorderSizePixel = 0
Instance.new("UICorner", dot).CornerRadius = UDim.new(1, 0)
dot.Parent = gui
gui.Parent = playerGui
aimGui = gui
return gui
end
-- Drive the charge ring from the current draw alpha (0..1): grows + recolours yellow→green.
local function setRing(alpha: number?)
local gui = aimGui
if not gui then
return
end
local ring = gui:FindFirstChild("Ring") :: Frame
if not ring then
return
end
local a = math.clamp(alpha or 0, 0, 1)
local size = 22 + 22 * a
ring.Size = UDim2.fromOffset(size, size)
local stroke = ring:FindFirstChildOfClass("UIStroke")
if stroke then
stroke.Color = Color3.fromRGB(255, 200, 100):Lerp(Color3.fromRGB(120, 230, 140), a)
end
end
local function flashNoAmmo()
local gui = aimGui
local dot = gui and gui:FindFirstChild("Dot") :: Frame
if not dot then
return
end
dot.BackgroundColor3 = Color3.fromRGB(235, 90, 90)
task.delay(0.4, function()
if dot.Parent then
dot.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
end
end)
end
-- ===== Aim mode (right-click) ================================================
local function enterAim(tool: Tool)
if aiming then
return
end
aiming = true
drawTool = tool
local gui = ensureAimGui()
if gui then
gui.Enabled = true
setRing(0)
end
local cam = Workspace.CurrentCamera
if cam then
savedFov = cam.FieldOfView
TweenService
:Create(
cam,
TweenInfo.new(0.25, Enum.EasingStyle.Quad),
{ FieldOfView = math.max(40, cam.FieldOfView - 12) }
)
:Play()
end
local hum = humanoid()
if hum then
TweenService:Create(hum, TweenInfo.new(0.25, Enum.EasingStyle.Quad), { CameraOffset = Vector3.new(2, 0.5, 0) })
:Play()
end
end
local function exitAim()
if not aiming then
return
end
aiming = false
drawing = false
drawTool = nil
if aimGui then
aimGui.Enabled = false
end
local cam = Workspace.CurrentCamera
if cam and savedFov then
TweenService:Create(cam, TweenInfo.new(0.2), { FieldOfView = savedFov }):Play()
end
savedFov = nil
local hum = humanoid()
if hum then
TweenService:Create(hum, TweenInfo.new(0.2), { CameraOffset = Vector3.zero }):Play()
end
end
-- ===== Draw + fire (left-click while aiming) =================================
local function startDraw(tool: Tool)
if drawing then
return
end
drawing = true
drawTool = tool
drawT0 = os.clock()
drawTime = math.max(0.05, tonumber(tool:GetAttribute("WeaponDrawTime")) or 1)
Remotes.event("BowDraw"):FireServer()
setRing(0)
end
-- The world point the crosshair (camera centre) is over — the server fires from the bow toward it.
local function aimTargetPoint(): Vector3
local cam = Workspace.CurrentCamera
local vp = cam.ViewportSize
local ray = cam:ViewportPointToRay(vp.X / 2, vp.Y / 2)
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { player.Character :: any }
local result = Workspace:Raycast(ray.Origin, ray.Direction * 500, params)
return result and result.Position or (ray.Origin + ray.Direction * 500)
end
local function fireBow()
if not drawing then
return
end
drawing = false
local tool = equippedWeapon()
if tool and tool == drawTool then
local alpha = math.clamp((os.clock() - drawT0) / drawTime, 0, 1)
Remotes.event("BowRelease"):FireServer(aimTargetPoint(), alpha)
end
setRing(0)
end
-- ===== Melee (left-click) ====================================================
local function playSwing(tool: Tool)
local hum = humanoid()
local animator = hum and hum:FindFirstChildOfClass("Animator")
if not animator then
return
end
local toolType = tostring(tool:GetAttribute("ToolType") or tool.Name)
local animId = Assets.tryGet("WeaponAnims", toolType)
if animId == "" then
return
end
local anim = Instance.new("Animation")
anim.AnimationId = animId
local ok, track = pcall(function()
return animator:LoadAnimation(anim)
end)
if ok and track then
track:Play()
end
end
-- ===== Input routing =========================================================
local function primaryDown()
local tool, kind = equippedWeapon()
if not tool then
return
end
if kind == "melee" then
playSwing(tool)
Remotes.event("CombatSwing"):FireServer()
elseif kind == "bow" and aiming then
startDraw(tool)
end
end
local function primaryUp()
if drawing then
fireBow()
end
end
local function secondaryDown()
local tool, kind = equippedWeapon()
if tool and kind == "bow" then
enterAim(tool)
end
end
local function secondaryUp()
if aiming then
exitAim()
end
end
-- ===== Cosmetic arrow flying the server's arc ================================
-- Cumulative arc-length sampler over the server-sent path, so the arrow follows the real curve.
local function buildSampler(path: { Vector3 })
local cum = { 0 }
for i = 2, #path do
cum[i] = cum[i - 1] + (path[i] - path[i - 1]).Magnitude
end
local total = cum[#path]
return total,
function(frac: number): (Vector3, Vector3)
local target = math.clamp(frac, 0, 1) * total
for i = 2, #path do
if cum[i] >= target then
local seg = cum[i] - cum[i - 1]
local f = if seg > 1e-4 then (target - cum[i - 1]) / seg else 0
local p0, p1 = path[i - 1], path[i]
return p0:Lerp(p1, f), (p1 - p0).Unit
end
end
local n = #path
return path[n], (path[n] - path[n - 1]).Unit
end
end
local function showArrowPath(path: { Vector3 })
if not path or #path < 2 then
return
end
local total, sample = buildSampler(path)
if total < 0.1 then
return
end
local flight = math.clamp(total / 180, 0.08, 1.2)
local arrow = Instance.new("Part")
arrow.Size = Vector3.new(0.14, 0.14, 1.6)
arrow.Color = Color3.fromRGB(220, 210, 180)
arrow.Material = Enum.Material.Wood
arrow.Anchored = true
arrow.CanCollide = false
arrow.CanQuery = false
local p0, d0 = sample(0)
arrow.CFrame = CFrame.lookAt(p0, p0 + d0)
arrow.Parent = Workspace
task.spawn(function()
local t0 = os.clock()
while arrow.Parent do
local frac = (os.clock() - t0) / flight
if frac >= 1 then
break
end
local pos, dir = sample(frac)
arrow.CFrame = CFrame.lookAt(pos, pos + dir)
RunService.RenderStepped:Wait()
end
if arrow.Parent then
local pos, dir = sample(1)
arrow.CFrame = CFrame.lookAt(pos, pos + dir)
task.delay(1.5, function()
if arrow.Parent then
arrow:Destroy()
end
end)
end
end)
end
function CombatInput.start(_options: { [string]: any }?)
if started then
return
end
started = true
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then
return
end
local t = input.UserInputType
if t == Enum.UserInputType.MouseButton1 or t == Enum.UserInputType.Touch then
primaryDown()
elseif t == Enum.UserInputType.MouseButton2 then
secondaryDown()
end
end)
UserInputService.InputEnded:Connect(function(input)
local t = input.UserInputType
if t == Enum.UserInputType.MouseButton1 or t == Enum.UserInputType.Touch then
primaryUp()
elseif t == Enum.UserInputType.MouseButton2 then
secondaryUp()
end
end)
-- Fill the charge ring while drawing.
RunService.RenderStepped:Connect(function()
if drawing then
setRing((os.clock() - drawT0) / drawTime)
end
end)
-- Drop aim state if the character/weapon goes away.
player.CharacterAdded:Connect(function()
aiming, drawing, drawTool, savedFov = false, false, nil, nil
if aimGui then
aimGui.Enabled = false
end
end)
Remotes.event("CombatResult").OnClientEvent:Connect(function(result)
if not result then
return
end
if result.source == "bow" then
if result.reason == "ammo" then
flashNoAmmo()
elseif result.path then
showArrowPath(result.path)
end
end
local key = if result.hit then "hit" else "miss"
local soundId = Assets.tryGet("CombatSounds", key)
if soundId ~= "" then
local sound = Instance.new("Sound")
sound.SoundId = soundId
sound.Parent = SoundService
sound:Play()
sound.Ended:Once(function()
sound:Destroy()
end)
end
end)
end
return CombatInput
+5
View File
@@ -72,6 +72,11 @@ local function playSwing(tool: Tool)
end
local function onActivated(tool: Tool)
-- A weapon (carries WeaponKind) routes to CombatInput, not here — so the two input paths never
-- double-fire on the same click. Harvest tools have no WeaponKind and fall through.
if typeof(tool:GetAttribute("WeaponKind")) == "string" then
return
end
local node = findGatherableUnderMouse()
if not node or not inRange(node) then
return
+42
View File
@@ -0,0 +1,42 @@
--[[
Mob the creature component. A creator builds (or imports) any rigged Model with a Humanoid +
PrimaryPart, tags it "Mob", and sets `MobType = "<id>"` to inherit health/speed/ranges/faction
from a `SurvivorCore.Mobs` def (authored in code or no-code via the admin plugin). Per-instance
attributes (Faction, Health, AggroRange, ) override the def for that one mob.
On setup the node is handed to the server `Mobs` runtime (`Mobs.adopt`), which resolves its
effective stats, sets the Humanoid health, and starts the AI FSM (idle/wander/chase/attack/flee).
Combat damages it like any Humanoid; per-type juice (death fade, spawn cry) attaches via
`SurvivorCore.Mobs.onReaction` no core edits. This is the Builder-UI-drivable creature path
(#11/#14): the attribute set below is the schema the admin plugin's Mobs editor writes.
]]
local Components = require(script.Parent)
local Mobs = require(script.Parent.Parent.systems.Mobs)
return Components.define({
name = "Mob",
tag = "Mob",
attributes = {
MobType = "", -- the Mobs def id to inherit from; blank = use the model's Name
Faction = "", -- "hostile" | "passive" | "neutral"; blank = inherit the def (default "neutral")
Health = 0, -- 0 = inherit the def (or 50)
WalkSpeed = 0, -- 0 = inherit
RunSpeed = 0, -- 0 = inherit
AggroRange = 0, -- 0 = inherit / Config default
LeashRange = 0, -- 0 = inherit / Config default
AttackRange = 0, -- 0 = inherit / Config default
AttackDamage = 0, -- 0 = inherit / Config default
AttackCooldown = 0, -- 0 = inherit / Config default
WanderRadius = 0, -- 0 = inherit / Config default
},
onSetup = function(instance, _values)
-- The server Mobs runtime reads the instance attributes directly (one merge path:
-- override attr registry def "Mobs" Config), so nothing to stash here — just adopt it.
if instance:IsA("Model") then
Mobs.adopt(instance)
else
warn(`[Mob] '{instance:GetFullName()}' must be a Model (Humanoid + PrimaryPart) to be a mob`)
end
end,
})
+12
View File
@@ -9,6 +9,18 @@
This is how game-specific flourish (tree-felling physics, station VFX, custom drops)
stays OUT of the engine while remaining first-class.
Engine-fired hooks (ctx is a single table unless noted):
gather:hit / gather:depleted / gather:blocked { instance, player, resource, item?, }
craft:start / craft:end / craft:blocked { station?, recipe, player, reason? }
item:use { player, itemId, }
mob:spawned / mob:hit / mob:died { instance, mobType, player?, killer?, position? }
mob:attack { instance, mobType, player, damage, position }
combat:hit { attacker, victim, weapon, source, damage, victimHpLeft }
combat:kill { attacker, victim, weapon, source } -- source "melee"|"bow"
Per-resource / per-mob-type variants of these dispatch through Reactions (see Reactions.luau):
SurvivorCore.Gather.onReaction(resourceId, ) and SurvivorCore.Mobs.onReaction(mobType, ).
]]
local Hooks = {}
+40 -1
View File
@@ -49,9 +49,14 @@ require(script.shared.UiConfig)
require(script.shared.HarvestingConfig)
require(script.shared.CraftingConfig)
-- Define the "Mobs" (AI tick/aggro/leash) and "Combat" (melee range/cooldown + bow) Config sections,
-- so Config.override(...) works any time before start()/startClient().
require(script.shared.MobsConfig)
require(script.shared.CombatConfig)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.4.0"
SurvivorCore.VERSION = "0.5.0"
-- Foundation
SurvivorCore.Config = Config
@@ -73,6 +78,12 @@ SurvivorCore.Codex = Registries.Codex
SurvivorCore.Appearance = Registries.Appearance
SurvivorCore.Mobs = Registries.Mobs
-- Mob juice: per-mob-type reaction hooks (death fade, spawn cry, hit flinch). Like Gather, safe to
-- register any time; the Mobs FSM dispatches them. SurvivorCore.Mobs.onReaction(id, event, fn) where
-- event = "spawned" | "hit" | "attack" | "died". The runtime API (spawn/adopt/damage/getActive/
-- isMob) is attached after start() (server-only, acts on live mobs).
SurvivorCore.Mobs.onReaction = Reactions.on
-- Creator-facing component layer
SurvivorCore.Components = Components
@@ -91,11 +102,17 @@ function SurvivorCore.start(_options: { [string]: any }?)
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
if content then
Registries.Items.loadFromFolder(content:FindFirstChild("Items"))
-- Weapons and Arrows ARE items (category = "weapon" / "ammo"); the admin plugin authors them in
-- their own folders so its editors don't collide with the Items editor — load both into Items.
Registries.Items.loadFromFolder(content:FindFirstChild("Weapons"))
Registries.Items.loadFromFolder(content:FindFirstChild("Arrows"))
Registries.Resources.loadFromFolder(content:FindFirstChild("Resources"))
Registries.Mobs.loadFromFolder(content:FindFirstChild("Mobs"))
end
-- Load built-in components so their tags are recognised.
require(script.components.Gatherable)
require(script.components.Mob)
-- TODO (extraction): boot order — Config merge → Assets → persistence → systems.
Components.scan()
@@ -134,6 +151,24 @@ function SurvivorCore.start(_options: { [string]: any }?)
-- ToolEquip: hotbar → physical Tool bridge (an active tool item becomes a held Roblox Tool).
require(script.systems.ToolEquip).start(_options)
-- Mobs: the shared mob & AI engine (FSM substrate). Adopts any "Mob"-tagged model (done during
-- Components.scan above) and runs its AI; the runtime API acts on live mobs, so it's attached
-- here after start(): SurvivorCore.Mobs.spawn(type, cframe, opts) / adopt / damage / getActive /
-- isMob (registry register/get/getAll/query + onReaction were available before start()).
local mobs = require(script.systems.Mobs)
mobs.start(_options)
SurvivorCore.Mobs.spawn = mobs.spawn
SurvivorCore.Mobs.adopt = mobs.adopt
SurvivorCore.Mobs.damage = mobs.damage
SurvivorCore.Mobs.getActive = mobs.getActive
SurvivorCore.Mobs.isMob = mobs.isMob
-- Combat: server-authoritative melee + ranged (bow). Booted after Inventory (bow ammo) and Mobs
-- (its targets). Reuses the tool-swing pipeline; fires the combat:hit / combat:kill schema.
local combat = require(script.systems.Combat)
combat.start(_options)
SurvivorCore.Combat = combat
-- Harvesting: the authoritative hit resolver for gatherable nodes (prompt + tool-swing). Booted
-- after Inventory so per-hit yield can be granted (and a full inventory blocks the hit). The
-- module table exposes tryHarvest, which the Gatherable component's prompt path calls in.
@@ -179,6 +214,10 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
-- Tool-swing harvesting input (click an equipped tool at a gatherable node).
require(script.client.ToolHarvest).start(_options)
-- Combat input (click an equipped weapon: melee swing, or hold-to-draw a bow). Routes by the
-- held Tool's WeaponKind, so it coexists with ToolHarvest without double-firing.
require(script.client.CombatInput).start(_options)
-- Zero-setup net: if no authored menu/hotbar template reached the player, build the minimal
-- fallback (the binders pick it up via DescendantAdded, like HudFallback).
task.delay(2, function()
+38
View File
@@ -0,0 +1,38 @@
--!nonstrict
--[[
CombatConfig tuning for combat (issue #12). SHARED (the server validates every hit; the client
uses MeleeRange for its target pre-check and the Bow values for the draw meter). Defines the
"Combat" Config section so games retune via `Config.override("Combat", { ... })`.
Per-weapon values (damage, range, cooldown, draw time) live on the weapon ITEM def as flat
`weapon*` attributes and override these these are the fallbacks when a weapon leaves a field
unset. Read the merged section with CombatConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local CombatConfig = {}
CombatConfig.SECTION = "Combat"
CombatConfig.DEFAULTS = {
MeleeRange = 8, -- studs; fallback melee reach when a weapon has no weaponRange (server-validated)
MeleeCooldown = 0.6, -- seconds between accepted melee swings per player (anti-spam fallback)
RequireLineOfSight = true, -- raycast attacker→target; blocks melee hits through walls
FriendlyFire = false, -- false = players can't damage other players (mobs are always damageable)
Bow = {
Gravity = 80, -- studs/s² pulling the arrow down (arc); 0 = flat shot
ProjectileSpeed = 180, -- studs/s at full draw (fallback when a bow has no weaponProjectileSpeed)
MaxRange = 300, -- studs the server simulates a projectile before giving up
MinDrawDamageMult = 0.3, -- damage multiplier at zero draw; scales up to 1.0 at full draw
StepSize = 4, -- studs per raycast step when simulating the arc (smaller = more precise, costlier)
},
}
Config.defineSection(CombatConfig.SECTION, CombatConfig.DEFAULTS)
function CombatConfig.get(): any
return Config.get(CombatConfig.SECTION) or CombatConfig.DEFAULTS
end
return CombatConfig
+37
View File
@@ -0,0 +1,37 @@
--!nonstrict
--[[
MobsConfig tuning for the mob & AI engine (issue #17). SHARED (the server runs the FSM; the
client never needs it today, but it lives in shared like the other config sections). Defines the
"Mobs" Config section so games retune via `Config.override("Mobs", { ... })`.
These are ENGINE-WIDE defaults. Per-mob-type values (health, ranges, damage) live on the Mobs
registry def / the `Mob` component's attributes and override these — these only apply when a mob
leaves a field unset. Read the merged section with MobsConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local MobsConfig = {}
MobsConfig.SECTION = "Mobs"
MobsConfig.DEFAULTS = {
TickRate = 0.2, -- seconds between AI ticks per mob (the FSM cadence)
DefaultAggroRange = 40, -- studs; how close a player must be for a hostile mob to notice (or a passive to flee)
DefaultLeashRange = 60, -- studs from spawn; past this a chasing mob gives up and returns
DefaultAttackRange = 6, -- studs; melee reach of a hostile mob's attack
DefaultAttackDamage = 8, -- HP per landed mob attack
DefaultAttackCooldown = 1.5, -- seconds between a mob's attacks
DefaultWanderRadius = 12, -- studs around spawn a mob idles/wanders within
RequireLineOfSight = true, -- a hostile mob must see a player (raycast) to aggro
RespawnSeconds = 30, -- default respawn delay for mobs spawned with respawn = true (0 = no respawn)
CorpseSeconds = 5, -- how long a dead mob's body lingers before it's removed (a death reaction can fade it)
}
Config.defineSection(MobsConfig.SECTION, MobsConfig.DEFAULTS)
function MobsConfig.get(): any
return Config.get(MobsConfig.SECTION) or MobsConfig.DEFAULTS
end
return MobsConfig
+365
View File
@@ -0,0 +1,365 @@
--!nonstrict
--[[
Combat server. Server-authoritative melee + ranged (#12), reusing the v0.4.0 client-input
RemoteEvent server-validated-hit pipeline (the same shape as Harvesting). The client only
REQUESTS an attack; the server validates everything and decides the hit. NEVER trusts the client.
A weapon is just an Item with a `toolType` (so the hotbarTool bridge equips it) plus flat
`weapon*` fields: `weaponKind` ("melee" | "bow"), `weaponDamage`, `weaponRange`, `weaponCooldown`,
and for bows `weaponDrawTime`, `weaponProjectileSpeed`, `weaponMaxRange`, `weaponAmmo`. Flat
attributes so a weapon is fully no-code-authorable (the admin plugin's Weapons editor writes them).
Targets are anything with a Humanoid: engine `Mobs` (damaged via `Mobs.damage`, so the FSM gets
attribution + a `mob:hit` reaction) and — when `Config "Combat".FriendlyFire` is on — other
players. The kill-event schema is designed ONCE here:
combat:hit { attacker, victim, weapon, source, damage, victimHpLeft }
combat:kill { attacker, victim, weapon, source } -- source = "melee" | "bow"
fired via BOTH `Hooks` (engine extension) and `EventBridge` (analytics/quests/achievements), and
replacing TCE's overlapping zombie_killed / animal_killed / pvp_kill / bow_kill / spear_kill.
Tuning: the "Combat" Config section. Started by SurvivorCore.start().
]]
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.Combat is server-only — require it via SurvivorCore.start()")
local Registries = require(script.Parent.Parent.registries)
local Mobs = require(script.Parent.Mobs)
local Inventory = require(script.Parent.Inventory)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local EventBridge = require(script.Parent.Parent.foundation.EventBridge)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local CombatConfig = require(script.Parent.Parent.shared.CombatConfig)
local Combat = {}
local started = false
local lastSwing: { [Player]: number } = {}
local drawStart: { [Player]: number } = {}
local function playerRoot(player: Player): BasePart?
local char = player.Character
if not char then
return nil
end
return (char:FindFirstChild("HumanoidRootPart") or char.PrimaryPart) :: BasePart?
end
local function isAlive(player: Player): boolean
local char = player.Character
local hum = char and char:FindFirstChildOfClass("Humanoid")
return hum ~= nil and hum.Health > 0
end
-- The def for the player's equipped weapon (the held engine Tool's _ItemId → Items registry), or nil
-- if they're not holding a weapon of the wanted kind.
local function equippedWeapon(player: Player, wantKind: string): (any?, string?)
local char = player.Character
local tool = char and char:FindFirstChildOfClass("Tool")
if not tool then
return nil, nil
end
local itemId = tostring(tool:GetAttribute("_ItemId") or tool.Name)
local def = Registries.Items.get(itemId)
if not def then
return nil, nil
end
local kind = tostring(def.weaponKind or "")
if kind ~= wantKind then
return nil, nil
end
return def, itemId
end
-- A clear raycast from one point to another, ignoring the attacker + target instances.
local function clearShot(from: Vector3, to: Vector3, ignore: { Instance }): boolean
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = ignore
local result = Workspace:Raycast(from, to - from, params)
return result == nil
end
-- The fired hooks/events for a landed hit (and a kill, if the victim died). Designed once, here.
local function fireHit(
attacker: Player,
victim: Instance,
weapon: string,
source: string,
damage: number,
hum: Humanoid
)
local hpLeft = math.max(0, hum.Health)
local ctx = {
attacker = attacker,
victim = victim,
weapon = weapon,
source = source,
damage = damage,
victimHpLeft = hpLeft,
}
Hooks.run("combat:hit", ctx)
EventBridge.fire("combat:hit", attacker, { victim = victim, weapon = weapon, source = source, damage = damage })
if hum.Health <= 0 then
local kctx = { attacker = attacker, victim = victim, weapon = weapon, source = source }
Hooks.run("combat:kill", kctx)
EventBridge.fire("combat:kill", attacker, { victim = victim, weapon = weapon, source = source })
end
end
-- Apply combat damage to a victim model. Mobs route through Mobs.damage (attribution + reaction);
-- a player victim takes Humanoid damage directly (which syncs to their survival Health HUD).
local function applyDamage(
attacker: Player,
model: Model,
hum: Humanoid,
damage: number,
weapon: string,
source: string
)
if Mobs.isMob(model) then
Mobs.damage(model, damage, attacker)
else
hum:TakeDamage(damage)
end
fireHit(attacker, model, weapon, source, damage, hum)
end
local function fireResult(player: Player, payload: { [string]: any })
Remotes.event("CombatResult"):FireClient(player, payload)
end
-- ===== Melee =================================================================
-- Find the nearest valid melee target (mob, or another player if FriendlyFire) within range + LoS.
local function findMeleeTarget(attacker: Player, range: number): (Humanoid?, Model?)
local aroot = playerRoot(attacker)
if not aroot then
return nil, nil
end
local origin = aroot.Position
local achar = attacker.Character :: any
local cfg = CombatConfig.get()
local bestHum, bestModel, bestDist = nil, nil, range
local function consider(model: Model)
local hum = model:FindFirstChildOfClass("Humanoid")
local root = model.PrimaryPart or model:FindFirstChild("HumanoidRootPart")
if not hum or hum.Health <= 0 or not root then
return
end
local d = (root.Position - origin).Magnitude
if d > bestDist then
return
end
if cfg.RequireLineOfSight and not clearShot(origin, root.Position, { achar, model }) then
return
end
bestHum, bestModel, bestDist = hum, model, d
end
for _, model in Mobs.getActive() do
consider(model)
end
if cfg.FriendlyFire then
for _, other in Players:GetPlayers() do
if other ~= attacker and other.Character then
consider(other.Character)
end
end
end
return bestHum, bestModel
end
local function onMeleeSwing(player: Player)
if not isAlive(player) then
return
end
local def, itemId = equippedWeapon(player, "melee")
if not def then
return
end
local cfg = CombatConfig.get()
local cooldown = tonumber(def.weaponCooldown) or tonumber(cfg.MeleeCooldown) or 0.6
local now = os.clock()
if lastSwing[player] and now - lastSwing[player] < cooldown then
return
end
lastSwing[player] = now
local range = tonumber(def.weaponRange) or tonumber(cfg.MeleeRange) or 8
local damage = tonumber(def.weaponDamage) or 10
local hum, model = findMeleeTarget(player, range)
if hum and model then
applyDamage(player, model, hum, damage, itemId, "melee")
fireResult(player, { ok = true, source = "melee", hit = true })
else
fireResult(player, { ok = true, source = "melee", hit = false })
end
end
-- ===== Ranged (bow) ==========================================================
-- Per-shot arrow modifiers from the ammo item def (or neutral defaults for an ammo-free bow).
-- ammoDamage / ammoSpeed scale the bow's values; ammoDrop scales gravity (heavier arrow = more
-- curve/drop); ammoRange caps the flight independently. This is what makes arrow TYPES differ.
local function arrowMods(ammoId: string)
local d = ammoId ~= "" and Registries.Items.get(ammoId) or nil
return {
damage = (d and tonumber(d.ammoDamage)) or 1, -- damage multiplier
speed = (d and tonumber(d.ammoSpeed)) or 0, -- speed multiplier; 0 = use bow speed as-is
drop = (d and tonumber(d.ammoDrop)) or 1, -- gravity multiplier (curve); >1 drops faster
range = (d and tonumber(d.ammoRange)) or 0, -- max studs; 0 = use bow/Config default
}
end
-- Step a gravity-affected projectile via short raycasts. Returns the first Humanoid hit, its model,
-- the impact point, and a downsampled `path` of waypoints along the ARC (origin → impact) so the
-- client can render the curving flight that the server actually computed (no client-side guessing).
local function simulateArrow(
origin: Vector3,
dir: Vector3,
speed: number,
gravity: number,
maxRange: number,
ignore: { Instance }
): (Humanoid?, Model?, Vector3, { Vector3 })
local step = math.max(1, tonumber(CombatConfig.get().Bow.StepSize) or 4)
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = ignore
local path: { Vector3 } = { origin }
local waypointEvery = 10 -- studs between recorded visual waypoints
local lastRecord = 0
local pos = origin
local vel = dir.Unit * math.max(1, speed)
local g = Vector3.new(0, -gravity, 0)
local traveled = 0
while traveled < maxRange do
local dt = step / math.max(1, speed)
local nextPos = pos + vel * dt + 0.5 * g * dt * dt
vel = vel + g * dt
local seg = nextPos - pos
local result = Workspace:Raycast(pos, seg, params)
if result then
local model = result.Instance:FindFirstAncestorWhichIsA("Model")
local hum = model and model:FindFirstChildOfClass("Humanoid")
table.insert(path, result.Position)
return hum, model, result.Position, path
end
pos = nextPos
traveled += seg.Magnitude
if traveled - lastRecord >= waypointEvery then
table.insert(path, pos)
lastRecord = traveled
end
end
table.insert(path, pos)
return nil, nil, pos, path
end
-- `targetPoint` is the world point the client's crosshair (camera-centre ray) is aimed at; the
-- server recomputes the true fire direction from the bow's own origin, eliminating shoulder-camera
-- parallax and any client direction spoofing.
local function onBowRelease(player: Player, targetPoint: any, _clientAlpha: any)
if not isAlive(player) or typeof(targetPoint) ~= "Vector3" then
drawStart[player] = nil
return
end
local def, itemId = equippedWeapon(player, "bow")
if not def then
drawStart[player] = nil
return
end
local aroot = playerRoot(player)
if not aroot then
drawStart[player] = nil
return
end
-- Server-timed draw (anti-cheat): how long the player actually held, clamped to the draw time.
local cfg = CombatConfig.get()
local drawTime = math.max(0.01, tonumber(def.weaponDrawTime) or 1)
local held = drawStart[player] and (os.clock() - drawStart[player]) or 0
drawStart[player] = nil
local alpha = math.clamp(held / drawTime, 0, 1)
-- Consume ammo if the bow requires it (arrows must be in the inventory).
local ammo = tostring(def.weaponAmmo or "")
if ammo ~= "" then
if not Inventory.remove(player, ammo, 1) then
fireResult(player, { ok = false, source = "bow", reason = "ammo" })
return
end
end
-- Combine BOW stats (pullback: base speed/damage/draw) with ARROW stats (weight→drop, damage,
-- range, speed) and the draw strength.
local mods = arrowMods(ammo)
local drawScale = tonumber(cfg.Bow.MinDrawDamageMult) or 0.3
drawScale = drawScale + (1 - drawScale) * alpha
local baseSpeed = tonumber(def.weaponProjectileSpeed) or tonumber(cfg.Bow.ProjectileSpeed) or 180
local speed = (mods.speed > 0 and baseSpeed * mods.speed or baseSpeed) * drawScale
local gravity = (tonumber(cfg.Bow.Gravity) or 80) * mods.drop
local maxRange = (mods.range > 0 and mods.range)
or tonumber(def.weaponMaxRange)
or tonumber(cfg.Bow.MaxRange)
or 300
local damage = (tonumber(def.weaponDamage) or 10) * mods.damage * drawScale
local origin = aroot.Position + Vector3.new(0, 1.5, 0)
local dir = targetPoint - origin
if dir.Magnitude < 0.01 then
dir = aroot.CFrame.LookVector
end
local hum, model, _hitPos, path =
simulateArrow(origin, dir.Unit, speed, gravity, maxRange, { player.Character :: any })
local hit = false
if hum and model and hum.Health > 0 then
-- Don't let an arrow damage the shooter; friendly-fire still gated for player victims.
local victimPlayer = Players:GetPlayerFromCharacter(model)
local allowed = Mobs.isMob(model) or (victimPlayer ~= nil and victimPlayer ~= player and cfg.FriendlyFire)
if allowed then
applyDamage(player, model, hum, damage, itemId, "bow")
hit = true
end
end
-- `path` is the arc (origin → impact) so the client renders the curving flight, not a straight line.
fireResult(player, { ok = true, source = "bow", hit = hit, path = path })
end
function Combat.start(_options: { [string]: any }?)
if started then
return
end
started = true
-- Eager so clients can connect at startup (server→client feedback).
Remotes.event("CombatResult")
Remotes.event("CombatSwing").OnServerEvent:Connect(function(player)
onMeleeSwing(player)
end)
Remotes.event("BowDraw").OnServerEvent:Connect(function(player)
drawStart[player] = os.clock()
end)
Remotes.event("BowRelease").OnServerEvent:Connect(function(player, targetPoint, clientAlpha)
onBowRelease(player, targetPoint, clientAlpha)
end)
Players.PlayerRemoving:Connect(function(player)
lastSwing[player] = nil
drawStart[player] = nil
end)
end
return Combat
+8 -5
View File
@@ -289,11 +289,14 @@ local function tryAutoHotbar(player: Player, itemId: string)
end
end
-- Clear hotbar pins whose item no longer exists anywhere in inventory. Call after any removal.
local function cleanOrphanedHotbarPins(player: Player)
-- Clear hotbar pins whose item no longer exists anywhere in inventory. Pass `onlyItemId` to scope
-- the sweep to pins for that ONE item — so removing/consuming item X never clears an unrelated pin Y
-- (e.g. a hotbar-pinned weapon with no inventory stack). nil sweeps every pin.
local function cleanOrphanedHotbarPins(player: Player, onlyItemId: string?)
for slot = 1, HOTBAR_SIZE do
local pinned = tostring(player:GetAttribute(InventoryTypes.hotbarSlotAttr(slot)) or "")
if pinned ~= "" and getTotalQty(player, pinned) == 0 then
local consider = pinned ~= "" and (onlyItemId == nil or pinned == onlyItemId)
if consider and getTotalQty(player, pinned) == 0 then
player:SetAttribute(InventoryTypes.hotbarSlotAttr(slot), nil)
if math.floor(tonumber(player:GetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR)) or 0) == slot then
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, nil)
@@ -329,7 +332,7 @@ local function consume(player: Player, itemId: string, slot: number?): boolean
end
end
cleanOrphanedHotbarPins(player)
cleanOrphanedHotbarPins(player, itemId)
-- Every successful consume fires item:use — the seam for eat animations, sounds, or
-- modifier logic (e.g. Stats.removeModifier to stop an ongoing poison tick).
Hooks.run("item:use", { player = player, itemId = itemId, def = def, slot = slot })
@@ -364,7 +367,7 @@ function Inventory.remove(player: Player, itemId: string, amount: number?): bool
end
local ok = removeQty(player, id, math.max(1, math.floor(tonumber(amount) or 1)))
if ok then
cleanOrphanedHotbarPins(player)
cleanOrphanedHotbarPins(player, id)
fireChanged(player, "remove", { itemId = id })
end
return ok
+486
View File
@@ -0,0 +1,486 @@
--!nonstrict
--[[
Mobs server. The shared mob & AI engine (issue #17): the substrate combat (#12), animals (#13)
and monsters (#14) all build on. A "mob" is the engine's unified, content-free creature contract:
a Model, tagged "Mob", containing a Humanoid + a PrimaryPart (HumanoidRootPart).
Because a mob IS a Humanoid, combat damages it EXACTLY like a player (`Humanoid:TakeDamage`),
its health IS `Humanoid.Health`, and its death IS `Humanoid.Died` — one code path for players and
mobs. The engine ships ZERO creature content; a creator supplies the rigged model (or the engine
builds a minimal blocky placeholder), the `Mobs` registry def (health/speed/ranges/faction), and
the death/spawn juice via `SurvivorCore.Mobs.onReaction`.
A reusable FSM drives behavior, with the profile chosen by DATA (the def's `faction`):
"hostile" idle/wander chase (line-of-sight + leash) attack return on leash break.
"passive" idle/wander flee from a near (or attacking) player.
"neutral" idle/wander only; flees when struck.
One shared scheduler ticks every active mob at `Config "Mobs".TickRate`. Movement is idiomatic
`Humanoid:MoveTo` (a creator's rig walks/animates naturally; PathfindingService is a future
enhancement). Tuning: the "Mobs" Config section. Started by SurvivorCore.start().
]]
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local CollectionService = game:GetService("CollectionService")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.Mobs is server-only — require it via SurvivorCore.start()")
local Registries = require(script.Parent.Parent.registries)
local Reactions = require(script.Parent.Parent.foundation.Reactions)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
local EventBridge = require(script.Parent.Parent.foundation.EventBridge)
local Assets = require(script.Parent.Parent.foundation.Assets)
local MobsConfig = require(script.Parent.Parent.shared.MobsConfig)
local Mobs = {}
local MOB_TAG = "Mob"
local BOUND_ATTR = "_MobBound"
local started = false
local schedulerRunning = false
-- model -> live AI state. The shared scheduler iterates this every tick.
local activeMobs: { [Model]: any } = {}
-- A non-empty number/string attribute, else nil — so "" / 0 fall through to the def / config default.
local function attrOr(model: Instance, name: string): any
local v = model:GetAttribute(name)
if v == nil or v == "" or v == 0 then
return nil
end
return v
end
-- Resolve a mob's effective fields: per-instance override attribute registry def "Mobs" Config.
-- One merge path, shared by tagged in-world mobs (the `Mob` component) and `Mobs.spawn` alike.
local function resolveMob(model: Model)
local mobType = tostring(model:GetAttribute("MobType") or model:GetAttribute("_MobType") or model.Name)
local def = Registries.Mobs.get(mobType) or {}
local cfg = MobsConfig.get()
local faction = tostring(attrOr(model, "Faction") or def.faction or "neutral")
if faction ~= "hostile" and faction ~= "passive" and faction ~= "neutral" then
faction = "neutral"
end
local function num(attr: string, defField: string, cfgField: string): number
return tonumber(attrOr(model, attr)) or tonumber(def[defField]) or tonumber(cfg[cfgField]) or 0
end
return {
mobType = mobType,
faction = faction,
health = tonumber(attrOr(model, "Health")) or tonumber(def.health) or 50,
walkSpeed = tonumber(attrOr(model, "WalkSpeed")) or tonumber(def.walkSpeed) or 6,
runSpeed = tonumber(attrOr(model, "RunSpeed")) or tonumber(def.runSpeed) or 14,
aggroRange = num("AggroRange", "aggroRange", "DefaultAggroRange"),
leashRange = num("LeashRange", "leashRange", "DefaultLeashRange"),
attackRange = num("AttackRange", "attackRange", "DefaultAttackRange"),
attackDamage = num("AttackDamage", "attackDamage", "DefaultAttackDamage"),
attackCooldown = num("AttackCooldown", "attackCooldown", "DefaultAttackCooldown"),
wanderRadius = num("WanderRadius", "wanderRadius", "DefaultWanderRadius"),
}
end
-- A minimal, content-free placeholder rig: one upright block (the HumanoidRootPart) + a Humanoid,
-- so the default mob walks via MoveTo with no creator model. Real games supply their own rigged
-- model template under ReplicatedStorage.SurvivorCoreContent.MobModels.<mobType>.
local function buildDefaultRig(name: string, faction: string): Model
local model = Instance.new("Model")
model.Name = name
local root = Instance.new("Part")
root.Name = "HumanoidRootPart"
root.Size = Vector3.new(2, 3, 1)
root.Color = if faction == "hostile" then Color3.fromRGB(150, 60, 60) else Color3.fromRGB(120, 130, 90)
root.Material = Enum.Material.SmoothPlastic
root.Parent = model
-- A small "head" for personality (welded, no collision, massless so it doesn't affect movement).
local head = Instance.new("Part")
head.Name = "Head"
head.Shape = Enum.PartType.Ball
head.Size = Vector3.new(1.4, 1.4, 1.4)
head.Color = root.Color
head.CanCollide = false
head.Massless = true
head.CFrame = root.CFrame * CFrame.new(0, 2, 0)
head.Parent = model
local weld = Instance.new("WeldConstraint")
weld.Part0 = root
weld.Part1 = head
weld.Parent = root
local hum = Instance.new("Humanoid")
hum.Parent = model
model.PrimaryPart = root
return model
end
local function mobRoot(model: Model): BasePart?
return model.PrimaryPart or model:FindFirstChild("HumanoidRootPart") :: BasePart?
end
-- The nearest LIVE player within `maxDist` of a point (and their root). Mobs only target players.
local function nearestPlayer(pos: Vector3, maxDist: number): (Player?, number, BasePart?)
local best, bestDist, bestRoot = nil, maxDist, nil
for _, player in Players:GetPlayers() do
local char = player.Character
local hum = char and char:FindFirstChildOfClass("Humanoid")
local root = char and char:FindFirstChild("HumanoidRootPart")
if hum and hum.Health > 0 and root then
local d = (root.Position - pos).Magnitude
if d <= bestDist then
best, bestDist, bestRoot = player, d, root
end
end
end
return best, bestDist, bestRoot
end
-- Clear sight from the mob to a target part (raycast, ignoring the mob's own model + the target char).
local function hasLineOfSight(from: BasePart, mobModel: Model, targetRoot: BasePart): boolean
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { mobModel, targetRoot.Parent :: Instance }
local origin = from.Position
local result = Workspace:Raycast(origin, (targetRoot.Position - origin), params)
return result == nil
end
local function moveTo(s: any, pos: Vector3, speed: number)
if s.humanoid.WalkSpeed ~= speed then
s.humanoid.WalkSpeed = speed
end
s.humanoid:MoveTo(pos)
end
-- Pick a fresh wander destination near spawn (used while idle/unaggroed).
local function wanderPoint(s: any): Vector3
local r = s.wanderRadius
local off = Vector3.new(math.random(-r, r), 0, math.random(-r, r))
return s.spawnPos + off
end
local function playMobAnim(s: any, state: string)
local animId = Assets.tryGet("MobAnims", s.mobType .. "_" .. state)
if animId == "" then
return
end
local animator = s.humanoid:FindFirstChildOfClass("Animator")
if not animator then
return
end
local anim = Instance.new("Animation")
anim.AnimationId = animId
local ok, track = pcall(function()
return animator:LoadAnimation(anim)
end)
if ok and track then
track:Play()
end
end
-- A hostile mob lands a hit on a player: damage the player's Humanoid (lethal — it syncs to the
-- survival Health HUD), respecting the per-mob attack cooldown. Fires `mob:attack`.
local function tryAttack(s: any, target: Player, targetRoot: BasePart)
local now = os.clock()
if now - (s.lastAttack or 0) < s.attackCooldown then
return
end
s.lastAttack = now
playMobAnim(s, "attack")
local hum = target.Character and target.Character:FindFirstChildOfClass("Humanoid")
if hum and hum.Health > 0 then
hum:TakeDamage(s.attackDamage)
end
local ctx = {
instance = s.model,
mobType = s.mobType,
player = target,
damage = s.attackDamage,
position = targetRoot.Position,
}
Hooks.run("mob:attack", ctx)
Reactions.run(s.mobType, "attack", ctx)
EventBridge.fire("mob:attack", target, { mob = s.model, mobType = s.mobType, damage = s.attackDamage })
end
-- One FSM tick for one mob. Behavior profile is chosen by faction (data), not by subclassing.
local function step(s: any)
local root = mobRoot(s.model)
if not root then
return
end
local pos = root.Position
local player, dist, targetRoot = nearestPlayer(pos, math.max(s.aggroRange, 1))
if s.faction == "hostile" then
local seesPlayer = player ~= nil
and (not s.requireLoS or hasLineOfSight(root, s.model, targetRoot))
and (pos - s.spawnPos).Magnitude <= s.leashRange
if seesPlayer then
s.current = "chase"
if dist <= s.attackRange then
moveTo(s, pos, s.walkSpeed) -- stop closing; hold position and swing
tryAttack(s, player, targetRoot)
else
moveTo(s, targetRoot.Position, s.runSpeed)
end
return
end
-- No valid target: if we wandered off chasing, walk back to spawn; otherwise idle/wander.
if (pos - s.spawnPos).Magnitude > s.wanderRadius + 2 and s.current == "chase" then
s.current = "return"
end
elseif s.faction == "passive" then
local threatened = player ~= nil and (dist <= s.aggroRange or os.clock() < (s.fleeUntil or 0))
if threatened then
s.current = "flee"
local away = (pos - targetRoot.Position)
away = if away.Magnitude > 0.1 then away.Unit else Vector3.new(0, 0, 1)
moveTo(s, pos + away * 16, s.runSpeed)
return
end
else
-- neutral: flee briefly only if recently struck, else just wander.
if os.clock() < (s.fleeUntil or 0) and targetRoot then
local away = (pos - targetRoot.Position)
away = if away.Magnitude > 0.1 then away.Unit else Vector3.new(0, 0, 1)
moveTo(s, pos + away * 12, s.runSpeed)
return
end
end
-- Idle / wander / return-to-spawn fallback (shared by every faction when not engaged).
if s.current == "return" then
moveTo(s, s.spawnPos, s.walkSpeed)
if (pos - s.spawnPos).Magnitude < 4 then
s.current = "idle"
end
return
end
if os.clock() >= (s.wanderUntil or 0) then
if s.current == "wander" then
s.current = "idle"
s.wanderUntil = os.clock() + math.random(2, 5)
else
s.current = "wander"
s.wanderUntil = os.clock() + math.random(3, 6)
moveTo(s, wanderPoint(s), s.walkSpeed)
end
end
end
-- The mob died: fire the lifecycle (Hooks + per-type Reactions + EventBridge), let a death reaction
-- transform the body, then clean up — and respawn if requested.
local function onDied(s: any)
if s.dead then
return
end
s.dead = true
s.current = "dead"
activeMobs[s.model] = nil
local root = mobRoot(s.model)
local ctx = {
instance = s.model,
mobType = s.mobType,
killer = s.lastDamageBy,
position = root and root.Position or nil,
}
Hooks.run("mob:died", ctx)
Reactions.run(s.mobType, "died", ctx)
EventBridge.fire("mob:died", s.lastDamageBy, { mob = s.model, mobType = s.mobType })
local corpse = tonumber(MobsConfig.get().CorpseSeconds) or 5
local model = s.model
task.delay(corpse, function()
if model and model.Parent then
model:Destroy()
end
end)
if s.respawn and s.respawnSeconds > 0 and s.spawnCFrame then
local mobType, cf, seconds, respawn = s.mobType, s.spawnCFrame, s.respawnSeconds, true
task.delay(seconds, function()
Mobs.spawn(mobType, cf, { respawn = respawn, respawnSeconds = seconds })
end)
end
end
-- One shared scheduler ticks every active mob. Started lazily on the first adopt (so the engine
-- works whether mobs are placed in-world before start() or spawned after it).
local function ensureScheduler()
if schedulerRunning then
return
end
schedulerRunning = true
task.spawn(function()
while true do
local tickRate = tonumber(MobsConfig.get().TickRate) or 0.2
task.wait(tickRate)
for model, s in activeMobs do
if model.Parent and not s.dead then
local ok, err = pcall(step, s)
if not ok then
warn(`[SurvivorCore.Mobs] '{s.mobType}' AI error: {tostring(err)}`)
end
elseif not model.Parent then
activeMobs[model] = nil
end
end
end
end)
end
-- Bring a Model (tagged "Mob", with a Humanoid + PrimaryPart) under engine AI control. Idempotent —
-- safe to call from both the `Mob` component's onSetup and `Mobs.spawn`. Returns the live state.
function Mobs.adopt(model: Model): any?
if typeof(model) ~= "Instance" or not model:IsA("Model") then
return nil
end
if model:GetAttribute(BOUND_ATTR) then
return activeMobs[model]
end
local hum = model:FindFirstChildOfClass("Humanoid")
local root = mobRoot(model)
if not hum or not root then
warn(`[SurvivorCore.Mobs] '{model:GetFullName()}' needs a Humanoid + PrimaryPart to be a mob`)
return nil
end
local r = resolveMob(model)
hum.MaxHealth = r.health
hum.Health = r.health
hum.WalkSpeed = r.walkSpeed
local s: any = {
model = model,
humanoid = hum,
mobType = r.mobType,
faction = r.faction,
walkSpeed = r.walkSpeed,
runSpeed = r.runSpeed,
aggroRange = r.aggroRange,
leashRange = r.leashRange,
attackRange = r.attackRange,
attackDamage = r.attackDamage,
attackCooldown = r.attackCooldown,
wanderRadius = r.wanderRadius,
requireLoS = MobsConfig.get().RequireLineOfSight ~= false,
spawnPos = root.Position,
spawnCFrame = model:GetPivot(),
current = "idle",
lastAttack = 0,
lastDamageBy = nil,
dead = false,
respawn = false,
respawnSeconds = tonumber(MobsConfig.get().RespawnSeconds) or 0,
}
model:SetAttribute(BOUND_ATTR, true)
model:SetAttribute("_MobType", r.mobType)
activeMobs[model] = s
ensureScheduler()
hum.Died:Once(function()
onDied(s)
end)
local ctx = { instance = model, mobType = r.mobType, position = root.Position }
Hooks.run("mob:spawned", ctx)
Reactions.run(r.mobType, "spawned", ctx)
return s
end
-- Spawn a mob of `mobType` at a CFrame. Clones a creator template
-- (ReplicatedStorage.SurvivorCoreContent.MobModels.<mobType>) if present, else builds a placeholder.
-- opts.respawn = true re-spawns it (after opts.respawnSeconds, default config) when it dies.
function Mobs.spawn(mobType: string, cframe: CFrame, opts: { [string]: any }?): Model?
local o: { [string]: any } = opts or {}
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
local models = content and content:FindFirstChild("MobModels")
local template = models and models:FindFirstChild(mobType)
local def = Registries.Mobs.get(mobType) or {}
local faction = tostring(def.faction or "neutral")
local model: Model
if template and template:IsA("Model") then
model = template:Clone()
else
model = buildDefaultRig(mobType, faction)
end
model.Name = mobType
model:SetAttribute("MobType", mobType)
model:PivotTo(cframe)
CollectionService:AddTag(model, MOB_TAG)
model.Parent = Workspace
local s = Mobs.adopt(model)
if s then
s.respawn = o.respawn == true
s.respawnSeconds = tonumber(o.respawnSeconds) or s.respawnSeconds
s.spawnCFrame = cframe
end
return model
end
-- Apply combat (or scripted) damage to a mob: damage its Humanoid (death is handled by Humanoid.Died)
-- and record the attacker for kill attribution + the `mob:hit` reaction. Called by the Combat system.
function Mobs.damage(model: Model, amount: number, source: Player?): boolean
local s = activeMobs[model]
local hum = model and model:FindFirstChildOfClass("Humanoid")
if not hum or hum.Health <= 0 then
return false
end
if s then
s.lastDamageBy = source
-- A struck passive/neutral mob flees for a few seconds even if the player then backs off.
s.fleeUntil = os.clock() + 5
local ctx = {
instance = model,
mobType = s.mobType,
player = source,
damage = amount,
hpLeft = math.max(0, hum.Health - amount),
}
Hooks.run("mob:hit", ctx)
Reactions.run(s.mobType, "hit", ctx)
end
hum:TakeDamage(amount)
return true
end
-- True if an instance is (part of) a live engine mob — used by Combat target selection.
function Mobs.isMob(model: Instance?): boolean
return model ~= nil and model:IsA("Model") and activeMobs[model] ~= nil
end
function Mobs.getActive(): { Model }
local out = {}
for model in activeMobs do
table.insert(out, model)
end
return out
end
function Mobs.start(_options: { [string]: any }?)
if started then
return
end
started = true
ensureScheduler()
end
return Mobs
+16
View File
@@ -52,10 +52,26 @@ end
local function makeTool(def: any, itemId: string): Tool
local tmpl = findTemplate(itemId)
local tool = if tmpl then tmpl:Clone() else buildDefaultTool(def.name or itemId)
if tmpl then
-- A held Tool's parts must be unanchored to weld to the hand; creator templates authored in
-- the world (e.g. the plugin's "+ Tool model") are often left anchored, so normalise here.
for _, p in tool:GetDescendants() do
if p:IsA("BasePart") then
p.Anchored = false
end
end
end
tool.Name = def.name or itemId
tool:SetAttribute(TOOL_MARKER, true)
tool:SetAttribute("_ItemId", itemId)
tool:SetAttribute("ToolType", def.toolType)
-- If this item is a weapon, stamp the client-readable routing attributes (the client routes melee
-- vs bow off WeaponKind, and sizes the bow draw meter off WeaponDrawTime). The SERVER re-reads the
-- authoritative weapon stats from the Items registry — these are for client input only.
if typeof(def.weaponKind) == "string" and def.weaponKind ~= "" then
tool:SetAttribute("WeaponKind", def.weaponKind)
tool:SetAttribute("WeaponDrawTime", tonumber(def.weaponDrawTime) or 0)
end
return tool
end
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "temujincalidius/survivorcore"
description = "Batteries-included, creator-extensible survival game framework for Roblox."
version = "0.4.0"
version = "0.5.0"
license = "MIT"
authors = ["Samuel Lison"]
registry = "https://github.com/UpliftGames/wally-index"