feat: hunting/butchering + death loot bags (#13, #19)

Hunting & butchering (#13): mob defs gain flat carcass fields (carcassItem/Hp/
Tool/YieldMin/Max/Seconds); a slain mob leaves a butcherable CARCASS that is a
tagged Gatherable — the whole harvesting pipeline (tool gate, per-hit yields,
HP bar, gather:* hooks, progression counters) is reused as the butcher flow.
Carcass looks come from SurvivorCoreContent.Carcasses.<mobType>; reactions key
on "<mobType>_carcass"; fields editable in the admin Mobs editor. Gatherable
gains generic PromptText/PromptObject attributes.

Death loot bags (#19, TCE port): dying drops inventory AND worn equipment
(config-toggleable) into an anchored ground-clamped bag — IntValue contents,
instant-loot prompt for anyone, floating countdown, owner-only beacon, death
toast, LifetimeSeconds despawn. Pickup is loss-proof: equipment restores to
empty slots first (satchel re-grows capacity before stacks), the rest grants
up-to-fit and the remainder stays bagged. New player:died lifecycle → deaths
counters via Progression; lootbag:dropped/collected hooks; TCE respawn camera
fix. New Inventory APIs: getEquipment, clearAll (capacity-safe order), addUpTo,
restoreEquip.

Fixed: ToolEquip forces CanBeDropped=false on cloned creator templates.

Demo: stone_knife + raw_meat (risky raw: Hunger -25, Poison +8), boar carcass,
multi-objective hunt_boar quest, hunter/butcher/hard_way achievements. Docs:
mobs.md butchering section + loot-bags.md; CHANGELOG; site card copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-07-06 14:05:22 +10:00
co-authored by Claude Opus 4.8
parent 991d1b0d80
commit 9951e09a3f
20 changed files with 959 additions and 8 deletions
+31
View File
@@ -5,6 +5,37 @@ 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`.
## Unreleased
### Added
- **Hunting & butchering** (#13) — slaying a mob whose def sets **carcass fields**
(`carcassItem` / `carcassHp` / `carcassTool` / `carcassYieldMin`/`Max` / `carcassSeconds`) leaves
a **butcherable carcass** — literally a tagged `Gatherable`, so butchering reuses the entire
proven harvesting pipeline: tool gate (a knife `toolType`), per-interaction yields, floating HP
bar, `gather:*` hooks and progression counters (`gathers_raw_meat` feeds quests + achievements
automatically). Carcass looks come from `SurvivorCoreContent.Carcasses.<mobType>` (placeholder
otherwise), per-type juice keys on `"<mobType>_carcass"`, and all six fields are editable in the
admin plugin's **Mobs** editor. Completes the passive-animal hunting loop the v0.5 FSM began.
New generic `Gatherable` attributes: **`PromptText` / `PromptObject`** (custom prompt wording for
any gather node). See [docs/mobs.md](docs/mobs.md).
- **Death loot bags & respawn** (#19, TCE port) — dying drops the player's inventory **and worn
equipment** (config-toggleable) into an **anchored, ground-clamped loot bag**: contents as
IntValues, an instant-loot prompt anyone can use, a floating **countdown**, an owner-only golden
**beacon**, a "You died" toast, and despawn after `LifetimeSeconds`. Pickup is loss-proof —
equipment restores to empty equip slots FIRST (a satchel re-grows slots/weight before stacks
return), the rest grants up-to-fit and the remainder stays in the bag. New `LootBags` Config
section + `SurvivorCore.LootBags`; new lifecycle `player:died` (fired for every death, bag or
not) flows into Progression as **`deaths_total`** counters; new hooks `lootbag:dropped` /
`lootbag:collected`. The TCE **respawn camera fix** rides along (CameraSubject re-pointed per
respawn). See [docs/loot-bags.md](docs/loot-bags.md).
- **Inventory APIs** — `getEquipment`, `clearAll` (capacity-safe snapshot-and-wipe),
`addUpTo` (granted-count adds), `restoreEquip` (direct empty-slot equip) — the death/restore
primitives, public for games to reuse.
### Fixed
- Engine-equipped Tools cloned from creator templates are now forced `CanBeDropped = false` — a
template left droppable would strand a stray pickable Tool in the world on death.
## 0.6.0 — 2026-07-03
### Added
+5 -1
View File
@@ -37,6 +37,9 @@ authoring tools. If you know Roblox Studio, you can build a survival game.
- **Quests & achievements** — event-driven goals: quest chains with objectives, rewards and
quest-giver NPCs, plus milestone achievements with toasts — both tracked automatically from
what players already do (gather, craft, fight).
- **Hunting & loot bags** — slain animals leave butcherable carcasses (knife required, real
yields); player death drops everything into a lootable bag with a countdown — get back to it
before it's gone.
- **No-code admin plugin** — create items, weapons, ammo and mobs from a Studio form (damage,
range, arrow curve, weight, aggro, leash) and drop them into the world. No scripting.
@@ -93,7 +96,8 @@ The **admin plugin** turns all of this into Studio forms — see
[Architecture](docs/architecture.md) · [Survival stats + HUD](docs/survival-stats.md) ·
[Inventory](docs/inventory.md) · [Harvesting](docs/harvesting.md) · [Crafting](docs/crafting.md) ·
[Combat](docs/combat.md) · [Mobs & AI](docs/mobs.md) · [Quests](docs/quests.md) ·
[Achievements](docs/achievements.md) · [No-code content](docs/content-authoring.md) ·
[Achievements](docs/achievements.md) · [Loot bags](docs/loot-bags.md) ·
[No-code content](docs/content-authoring.md) ·
[Admin plugin](docs/admin-plugin.md) · [Design language](docs/design-language.md) ·
[Extending](docs/extending.md)
+66
View File
@@ -143,6 +143,29 @@ SurvivorCore.Items.register({
})
-- Two arrow TYPES with different ballistics — exactly what the admin "Arrows / Ammo" editor authors.
-- Hunting: a knife to butcher carcasses, and the raw meat they yield (risky eaten raw — cook it
-- once stations arrive).
SurvivorCore.Items.register({
id = "stone_knife",
name = "Stone Knife",
description = "A keen flake of stone. Equip it to butcher carcasses.",
stack = 1,
weight = 0.6,
category = "tool",
toolType = "knife",
icon = "rbxassetid://129856164091801",
})
SurvivorCore.Items.register({
id = "raw_meat",
name = "Raw Meat",
description = "Fresh from the hunt. Eating it raw fills you up — and turns your stomach.",
stack = 10,
weight = 0.3,
category = "consumable",
onConsume = { Hunger = -25, Poison = 8 },
icon = "rbxassetid://138699077112926",
})
SurvivorCore.Items.register({
id = "arrow",
name = "Arrow",
@@ -211,7 +234,14 @@ SurvivorCore.Mobs.register({
walkSpeed = 6,
runSpeed = 24,
aggroRange = 28, -- bolts when you get within ~28 studs
-- Hunting: a slain boar leaves a butcherable carcass (a Gatherable — knife required).
carcassItem = "raw_meat",
carcassHp = 3,
carcassTool = "knife",
carcassYieldMin = 1,
carcassYieldMax = 2,
})
-- (The husk deliberately has NO carcass fields — blank carcassItem = no carcass on death.)
-- Quests: a small chain through the demo loop — gather → craft → fight. `gather_reeds` starts
-- automatically; finishing it auto-starts `weave_basket` (requires + autoStart); `slay_husk` is
@@ -241,6 +271,17 @@ SurvivorCore.Quests.register({
rewards = { { item = "heavy_arrow", count = 4 } },
turnIn = true, -- return to the giver post to claim
})
SurvivorCore.Quests.register({
id = "hunt_boar",
name = "The Hunt",
description = "Track the boar, take it down, and butcher it for meat.",
objectives = { -- multi-objective: both must complete
{ type = "kill", target = "boar", count = 1 },
{ type = "gather", target = "raw_meat", count = 2 },
},
rewards = { { item = "berry", count = 4 } },
autoStart = true,
})
-- Achievements: flat counter + threshold against the engine's auto-derived counters.
SurvivorCore.Achievements.register({
@@ -271,6 +312,27 @@ SurvivorCore.Achievements.register({
counter = "kills_husk",
threshold = 3,
})
SurvivorCore.Achievements.register({
key = "hunter",
name = "Hunter",
description = "Bring down your first boar.",
counter = "kills_boar",
threshold = 1,
})
SurvivorCore.Achievements.register({
key = "butcher",
name = "Butcher",
description = "Carve 3 cuts of raw meat.",
counter = "gathers_raw_meat",
threshold = 3,
})
SurvivorCore.Achievements.register({
key = "hard_way",
name = "The Hard Way",
description = "Die. It happens to everyone.",
counter = "deaths_total",
threshold = 1,
})
SurvivorCore.Items.register({
id = "straw_hat",
name = "Straw Hat",
@@ -343,11 +405,14 @@ local function seedPlayer(player: Player)
player:SetAttribute("InvQty_8", 1)
player:SetAttribute("InvSlot_9", "heavy_arrow")
player:SetAttribute("InvQty_9", 12)
player:SetAttribute("InvSlot_10", "stone_knife")
player:SetAttribute("InvQty_10", 1)
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("HotbarSlot6", "stone_knife") -- press 6: the knife — butcher a boar carcass
player:SetAttribute("EquipSlot_Head", "straw_hat") -- a pre-filled equipment slot
player:SetAttribute("EquipSlot_Back", "reed_satchel") -- equipped for +slots / +carry weight
end
@@ -511,6 +576,7 @@ local function buildWeaponTemplates()
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))
template("stone_knife", Vector3.new(0.3, 1.6, 0.3), Color3.fromRGB(160, 160, 165))
end
buildWeaponTemplates()
+5 -1
View File
@@ -47,6 +47,8 @@ ReplicatedStorage
│ • faction = "hostile" ("hostile" | "passive" | "neutral")
│ • health = 60
│ • aggroRange = 40
│ • carcassItem = "raw_meat" (hunting: blank = no carcass; see docs/mobs.md)
│ • carcassTool = "knife"
├─ Quests (Folder) ← flat single-objective quests (normalized at load)
│ └─ gather_reeds (Configuration)
│ • name = "Gather Reeds"
@@ -62,7 +64,9 @@ ReplicatedStorage
│ • counter = "kills_husk" (see docs/achievements.md for the counter catalogue)
│ • threshold = 3
├─ Tools (Folder) ← Tool templates the hotbar equips (named by item id)
─ MobModels (Folder) ← rigged mob templates Mobs.spawn clones (named by mob id)
─ MobModels (Folder) ← rigged mob templates Mobs.spawn clones (named by mob id)
├─ Carcasses (Folder) ← carcass models spawned on mob death (named by mob id)
└─ LootBag (Model/Part) ← the death loot-bag look (optional; placeholder otherwise)
```
Each child's **Name is the id**; its **attributes are the def fields**
+1
View File
@@ -156,6 +156,7 @@ Engine systems fire hooks with `Hooks.run("name", ctx)`. The full catalogue live
| `combat:hit` / `combat:kill` | combat ([docs](combat.md)) |
| `quest:started` / `quest:progress` / `quest:completed` / `quest:blocked` | quests ([docs](quests.md)) |
| `achievement:unlocked` | achievements ([docs](achievements.md)) |
| `player:died` · `lootbag:dropped` / `lootbag:collected` | death & loot bags ([docs](loot-bags.md)) |
These gameplay events ALSO cross the **EventBridge** with the same names — that bus is what quests,
achievements, and analytics consume (via the `Progression` translation layer,
+64
View File
@@ -0,0 +1,64 @@
# Death, loot bags & respawn
When a player dies, SurvivorCore drops their belongings into a **loot bag** at the spot they fell
([`src/systems/LootBags.luau`](../src/systems/LootBags.luau), ported from The Counter Earth). The
survival stakes system: get back to your bag before it's gone — or before someone else does.
## What happens on death
1. The inventory — and, by default, **worn equipment** too — is snapshotted and cleared
(`Inventory.clearAll`; ordering is capacity-safe, so satchel-granted slots never lose items).
2. `player:died` fires (Hooks + EventBridge, ctx `{ player, position }`) — **after** the clear, so
consumers see consistent state. The Progression stream maps it to a `death`, so
**`deaths_total`** counters and achievements work out of the box.
3. An **anchored** bag spawns at the death spot (ground-clamped by raycast — mid-air and water
deaths stay reachable), holding the contents as `IntValue` children (item id → count). A
floating **countdown** shows everyone how long it has left; the **owner** also gets a tall
golden **beacon** (client-side, only they see it) and a "You died" toast.
4. After `LifetimeSeconds` the bag despawns with whatever is still inside.
## Looting
**Anyone** may loot a bag (hold nothing — the prompt is instant). Pickup is loss-proof:
- **Equipment restores first** (the Back slot before the rest) straight onto empty equip slots —
so a dropped satchel re-grows your slot count and weight cap *before* the stacks pour back in.
- Stacks then grant **up to what fits** (`Inventory.addUpTo`); anything that doesn't fit **stays in
the bag** for another trip. The bag is destroyed only when it's empty.
## Configuration
```lua
Config.override("LootBags", {
Enabled = true, -- false = nothing drops (inventory persists through death)
DropEquipment = true, -- false = worn gear survives death; only carried items drop
LifetimeSeconds = 300,
InteractRange = 8,
ShowCountdown = true,
OwnerBeacon = true,
})
```
A creator-styled bag replaces the placeholder by adding a model named **`LootBag`** under
`ReplicatedStorage.SurvivorCoreContent`.
## Respawn camera
[`src/client/RespawnCamera.luau`](../src/client/RespawnCamera.luau) re-points `CameraSubject` at
the new Humanoid on every respawn (a TCE fix for the camera lingering on the old body). Survival
stats already reset per fresh body via [SurvivalConsequences](survival-stats.md).
## Hooks & events
| Event | Payload |
|---|---|
| `player:died` | `{ player, position }` |
| `lootbag:dropped` | `{ player, bag, position, items }` |
| `lootbag:collected` | `{ player, bag, emptied }` |
All three also cross the EventBridge. Progress is **session-scoped** — persistence (DataStore) is
a future system.
---
See also: [Mobs & hunting](mobs.md) · [Inventory](inventory.md) · [Combat](combat.md).
+30
View File
@@ -76,6 +76,36 @@ tagged placeholder rig for you.)
SurvivorCore.Mobs.spawn("husk", CFrame.new(40, 5, 20), { respawn = true })
```
## Hunting & butchering
Give a mob def **carcass fields** and slaying it leaves a **butcherable carcass** — which is simply
a tagged [`Gatherable`](harvesting.md) node, so butchering reuses the whole harvesting pipeline
(tool gate, per-hit yields, floating HP bar, `gather:*` hooks, progression counters):
```lua
SurvivorCore.Mobs.register({
id = "boar", faction = "passive", health = 40,
carcassItem = "raw_meat", -- blank = no carcass on death
carcassHp = 3, -- butcher interactions to deplete
carcassTool = "knife", -- tool type required ("" = bare-hand)
carcassYieldMin = 1,
carcassYieldMax = 2,
-- carcassSeconds = 120, -- lifetime before despawn (0 = stays until depleted)
})
```
The carcass uses a creator template (`SurvivorCoreContent.Carcasses.<mobType>`) or a plain
placeholder, and its prompt reads **"Butcher — <mobType> carcass"**. Per-type juice keys on
`"<mobType>_carcass"`:
```lua
SurvivorCore.Gather.onReaction("boar_carcass", "depleted", function(ctx) scatterBones(ctx.position) end)
```
Butcher counters (`gathers_raw_meat`, …) feed [quests](quests.md) and
[achievements](achievements.md) automatically. All six fields are editable in the admin plugin's
**Mobs** editor.
## Reactions (the juice)
Global `Hooks.on("mob:died", …)` fire for every mob. For behavior tied to **one** mob type, use the
+24
View File
@@ -225,6 +225,30 @@ ContentAdmin.CATEGORIES = {
{ 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 },
{
attr = "carcassItem",
kind = "string",
label = "Carcass yields item",
default = "",
placeholder = "item id; blank = no carcass",
},
{ attr = "carcassHp", kind = "number", label = "Carcass harvests (HP)", default = 3 },
{
attr = "carcassTool",
kind = "string",
label = "Carcass tool",
default = "",
placeholder = "knife (blank = bare-hand)",
},
{ attr = "carcassYieldMin", kind = "number", label = "Carcass yield min", default = 1 },
{ attr = "carcassYieldMax", kind = "number", label = "Carcass yield max", default = 1 },
{
attr = "carcassSeconds",
kind = "number",
label = "Carcass lifetime (s)",
default = 120,
placeholder = "0 = until depleted",
},
},
},
} :: { [string]: Category }
+3 -3
View File
@@ -124,14 +124,14 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3c2 3 1 5-1 7s-2 5 1 6c3-1 5-4 5-8 3 2 3 8-2 10s-11-1-11-7c0-4 4-6 4-9 1 .5 2 .8 4 1z"/></svg>
</span>
<h3>Combat — melee &amp; bow</h3>
<p>Click to swing; or aim a bow, draw for power, and loose arrows that <em>arc under real weight</em>. One server-validated kill-event schema.</p>
<p>Click to swing; or aim a bow, draw for power, and loose arrows that <em>arc under real weight</em>. Death drops a lootable bag — get back to it in time.</p>
</article>
<article class="card">
<span class="ico ico-ember" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M8 9h.01M16 9h.01M9 20l1-4M15 20l-1-4"/></svg>
</span>
<h3>Mobs &amp; AI</h3>
<p>A shared FSM substrate: hostile mobs chase &amp; attack, passive animals flee. Behavior is data — faction picks the profile.</p>
<h3>Mobs, AI &amp; hunting</h3>
<p>Hostile mobs chase &amp; attack, passive animals flee — and can be hunted and butchered for meat. Behavior is data — faction picks the profile.</p>
</article>
<article class="card">
<span class="ico ico-green" aria-hidden="true">
+88
View File
@@ -0,0 +1,88 @@
--!nonstrict
--[[
LootBagBeacon client. Owner-only "your stuff is over there" juice (TCE port).
When the server drops YOUR loot bag it fires `LootBagDropped` at you; this raises a tall golden
beam + a soft ground ring + a point light over the bag visible only to you (built locally).
Everything cleans itself up when the bag despawns or empties. Booted by startClient().
]]
local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore.LootBagBeacon is client-only — boot it via SurvivorCore.startClient()")
local Remotes = require(script.Parent.Parent.shared.Remotes)
local LootBagBeacon = {}
local started = false
local GOLD = Color3.fromRGB(255, 220, 80)
local function bagHost(bag: Instance): BasePart?
if bag:IsA("BasePart") then
return bag
end
return bag:FindFirstChildWhichIsA("BasePart")
end
local function buildBeacon(bag: Instance)
local host = bagHost(bag)
if not host then
return
end
local beam = Instance.new("Part")
beam.Name = "_LootBeacon"
beam.Anchored = true
beam.CanCollide = false
beam.CanQuery = false
beam.Material = Enum.Material.Neon
beam.Color = GOLD
beam.Transparency = 0.35
beam.Size = Vector3.new(0.8, 120, 0.8)
beam.CFrame = CFrame.new(host.Position + Vector3.new(0, 60, 0))
local ring = Instance.new("Part")
ring.Name = "_LootBeaconRing"
ring.Shape = Enum.PartType.Cylinder
ring.Anchored = true
ring.CanCollide = false
ring.CanQuery = false
ring.Material = Enum.Material.Neon
ring.Color = GOLD
ring.Transparency = 0.55
ring.Size = Vector3.new(0.2, 5, 5)
ring.CFrame = CFrame.new(host.Position + Vector3.new(0, 0.2, 0)) * CFrame.Angles(0, 0, math.rad(90))
ring.Parent = beam
local light = Instance.new("PointLight")
light.Color = GOLD
light.Brightness = 2.5
light.Range = 18
light.Parent = host
beam.Parent = workspace
-- Clean up when the bag goes away (looted empty or despawned).
bag.Destroying:Connect(function()
if beam.Parent then
beam:Destroy()
end
end)
end
function LootBagBeacon.start(_options: { [string]: any }?)
if started then
return
end
started = true
Remotes.event("LootBagDropped").OnClientEvent:Connect(function(bag, _lifetime)
if typeof(bag) == "Instance" then
buildBeacon(bag)
end
end)
end
return LootBagBeacon
+46
View File
@@ -0,0 +1,46 @@
--!nonstrict
--[[
RespawnCamera client. Ported from The Counter Earth's RespawnCameraFix.
When a character is replaced (respawn, or a game assigning player.Character directly), Roblox's
camera can stay subject-locked to the destroyed body. On every CharacterAdded, re-point
CameraSubject at the new Humanoid and restore the Custom camera type. Harmless when the default
behavior already did the right thing. Booted by SurvivorCore.startClient().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
assert(RunService:IsClient(), "SurvivorCore.RespawnCamera is client-only — boot it via SurvivorCore.startClient()")
local RespawnCamera = {}
local started = false
local player = Players.LocalPlayer
local function onCharacter(character: Model)
local humanoid = character:WaitForChild("Humanoid", 10)
if not humanoid then
return
end
local camera = Workspace.CurrentCamera
if camera then
camera.CameraSubject = humanoid
camera.CameraType = Enum.CameraType.Custom
end
end
function RespawnCamera.start(_options: { [string]: any }?)
if started then
return
end
started = true
player.CharacterAdded:Connect(onCharacter)
if player.Character then
task.spawn(onCharacter, player.Character)
end
end
return RespawnCamera
+6 -2
View File
@@ -77,6 +77,8 @@ return Components.define({
RequireTool = "", -- tool type needed (e.g. "axe"); "" = bare-hand
Interaction = "auto", -- "auto" | "prompt" | "tool"
DestroyOnDeplete = true, -- false keeps the node for a reaction to transform (stump/fell)
PromptText = "", -- prompt-mode ActionText override ("Butcher", "Pick"); "" = "Gather"
PromptObject = "", -- prompt-mode ObjectText override; "" = resource / item id
},
onSetup = function(instance, values)
local r = resolve(values)
@@ -103,8 +105,10 @@ return Components.define({
end
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Gather"
prompt.ObjectText = r.resource or (r.item ~= "" and r.item) or "Resource"
prompt.ActionText = if values.PromptText ~= "" then values.PromptText else "Gather"
prompt.ObjectText = if values.PromptObject ~= ""
then values.PromptObject
else (r.resource or (r.item ~= "" and r.item) or "Resource")
prompt.HoldDuration = 0.4
prompt.RequiresLineOfSight = false
prompt.Parent = host
+2
View File
@@ -21,6 +21,8 @@
quest:started / quest:progress / quest:completed / quest:blocked
{ player, questId, def?, index?, count?, reason? }
achievement:unlocked { player, key, def }
player:died { player, position } -- after any death-drop
lootbag:dropped / lootbag:collected { player, bag, position?, items? / emptied }
Per-resource / per-mob-type variants of these dispatch through Reactions (see Reactions.luau):
SurvivorCore.Gather.onReaction(resourceId, ) and SurvivorCore.Mobs.onReaction(mobType, ).
+15 -1
View File
@@ -59,9 +59,13 @@ require(script.shared.CombatConfig)
require(script.shared.QuestsConfig)
require(script.shared.AchievementsConfig)
-- Define the "LootBags" (death-drop) Config section, so Config.override(...) works any time
-- before start().
require(script.shared.LootBagsConfig)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.6.0"
SurvivorCore.VERSION = "0.7.0"
-- Foundation
SurvivorCore.Config = Config
@@ -220,6 +224,12 @@ function SurvivorCore.start(_options: { [string]: any }?)
SurvivorCore.Achievements.isUnlocked = achievements.isUnlocked
SurvivorCore.Achievements.getState = achievements.getState
-- LootBags: death drops the player's belongings into a lootable bag (+ player:died lifecycle,
-- deaths counters). Booted after Inventory (snapshot/clear APIs) and Progression (death map).
local lootBags = require(script.systems.LootBags)
lootBags.start(_options)
SurvivorCore.LootBags = lootBags
return SurvivorCore
end
@@ -256,6 +266,10 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
require(script.client.QuestsUi).start(_options)
require(script.client.AchievementsUi).start(_options)
-- Death & respawn juice: keep the camera on the new body, and beacon the owner's loot bag.
require(script.client.RespawnCamera).start(_options)
require(script.client.LootBagBeacon).start(_options)
-- Tool-swing harvesting input (click an equipped tool at a gatherable node).
require(script.client.ToolHarvest).start(_options)
+29
View File
@@ -0,0 +1,29 @@
--!nonstrict
--[[
LootBagsConfig tuning for death loot bags (issue #19). SHARED. Defines the "LootBags" Config
section so games retune via `Config.override("LootBags", { ... })`. Read the merged section
with LootBagsConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local LootBagsConfig = {}
LootBagsConfig.SECTION = "LootBags"
LootBagsConfig.DEFAULTS = {
Enabled = true, -- false = nothing drops on death (inventory persists through death)
DropEquipment = true, -- also drop WORN equipment (hat/satchel/…); false = only carried items
LifetimeSeconds = 300, -- how long a bag lingers before despawning (with whatever's left in it)
InteractRange = 8, -- studs; the bag's ProximityPrompt activation distance
ShowCountdown = true, -- a floating "time left" label above the bag (everyone sees it)
OwnerBeacon = true, -- a tall golden beam over the bag, visible ONLY to its owner
}
Config.defineSection(LootBagsConfig.SECTION, LootBagsConfig.DEFAULTS)
function LootBagsConfig.get(): any
return Config.get(LootBagsConfig.SECTION) or LootBagsConfig.DEFAULTS
end
return LootBagsConfig
+1
View File
@@ -26,6 +26,7 @@ MobsConfig.DEFAULTS = {
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)
DefaultCarcassSeconds = 120, -- how long a spawned carcass stays before despawning (0 = until depleted)
}
Config.defineSection(MobsConfig.SECTION, MobsConfig.DEFAULTS)
+118
View File
@@ -395,6 +395,124 @@ function Inventory.getSlots(player: Player): { { slot: number, itemId: string, q
return out
end
-- A read-only snapshot of WORN equipment: { { slot = "back", itemId = "reed_satchel" }, … }.
function Inventory.getEquipment(player: Player): { { slot: string, itemId: string } }
local out = {}
for _, slotName in InventoryTypes.EQUIP_SLOTS do
local id = tostring(player:GetAttribute(InventoryTypes.equipSlotAttr(slotName)) or "")
if id ~= "" then
table.insert(out, { slot = slotName, itemId = id })
end
end
return out
end
-- Add as many of `amount` as actually fit (weight + slots), one unit at a time, and return the
-- granted count. `Inventory.add` is all-or-nothing on weight but can PARTIALLY place on slot
-- exhaustion — loot-bag restore needs the exact number granted so nothing dupes or vanishes.
function Inventory.addUpTo(player: Player, itemId: string, amount: number): number
local id = sanitizeItemId(itemId)
if id == "" then
return 0
end
local granted = 0
for _ = 1, math.max(0, math.floor(tonumber(amount) or 0)) do
if not addQty(player, id, 1) then
break
end
granted += 1
end
if granted > 0 then
tryAutoHotbar(player, id)
fireChanged(player, "add", { itemId = id })
end
return granted
end
-- Directly restore an item into an EMPTY equip slot (the loot-bag pickup path: re-equipping a
-- dropped satchel FIRST re-grows slots/weight before ordinary stacks restore). Validates the def
-- actually belongs in that slot.
function Inventory.restoreEquip(player: Player, slotName: string, itemId: string): boolean
if not table.find(InventoryTypes.EQUIP_SLOTS, slotName) then
return false
end
local attr = InventoryTypes.equipSlotAttr(slotName)
if tostring(player:GetAttribute(attr) or "") ~= "" then
return false -- occupied; caller falls back to a normal stack grant
end
local def = readDef(sanitizeItemId(itemId))
local slotOk = def and typeof(def.equipment) == "table" and def.equipment.slot == slotName
if not slotOk then
return false
end
player:SetAttribute(attr, def.id or itemId)
recalcBackpackCapacity(player)
refreshWeight(player)
fireChanged(player, "equip", { itemId = itemId, equipSlot = slotName })
return true
end
-- Snapshot-and-clear EVERYTHING (death path). Returns the snapshot so the caller (loot bags) can
-- spill it into the world. Order is load-bearing: slots are captured and cleared while the
-- backpack bonus is still live — clearing the Back slot first would shrink MaxInvSlots and orphan
-- the bonus slots' items mid-flight.
function Inventory.clearAll(
player: Player,
opts: { equipment: boolean? }?
): {
slots: { { itemId: string, qty: number } },
equipment: { { slot: string, itemId: string } },
}
local dropEquipment = (opts and opts.equipment) ~= false
-- 1-2. Capture maxSlots with the satchel bonus live, snapshot slots — plus a defensive sweep
-- for orphaned InvSlot_N beyond the current max (leftovers from an earlier capacity shrink).
local maxSlots = getMaxSlots(player)
local slotNumbers: { number } = {}
for n = 1, maxSlots do
table.insert(slotNumbers, n)
end
for attr in player:GetAttributes() do
local n = tonumber(string.match(attr, "^InvSlot_(%d+)$"))
if n and n > maxSlots then
table.insert(slotNumbers, n)
end
end
local slots = {}
for _, n in slotNumbers do
local id = getSlotItemId(player, n)
if id ~= "" then
table.insert(slots, { itemId = id, qty = getSlotQty(player, n) })
end
end
-- 3. Snapshot equipment (before any clearing).
local equipment = if dropEquipment then Inventory.getEquipment(player) else {}
-- 4. Hotbar pins + the equipped-slot attr (ToolEquip drops the held tool off this signal).
for slot = 1, HOTBAR_SIZE do
player:SetAttribute(InventoryTypes.hotbarSlotAttr(slot), nil)
end
player:SetAttribute(InventoryTypes.HOTBAR_EQUIPPED_ATTR, nil)
-- 5. Clear the slots (including orphans), THEN 6. equipment.
for _, n in slotNumbers do
setSlot(player, n, "", 0)
end
if dropEquipment then
for _, slotName in InventoryTypes.EQUIP_SLOTS do
player:SetAttribute(InventoryTypes.equipSlotAttr(slotName), nil)
end
end
-- 7. Recalc capacity/weight once, and let every UI seam re-render.
recalcBackpackCapacity(player)
refreshWeight(player)
fireChanged(player, "clear")
return { slots = slots, equipment = equipment }
end
-- Move/merge/swap two inventory slots (the drag-and-drop primitive). Same item → merge up to
-- the stack cap (overflow stays in the source); different (or one empty) → swap.
function Inventory.move(player: Player, fromSlot: number, destSlot: number): boolean
+328
View File
@@ -0,0 +1,328 @@
--!nonstrict
--[[
LootBags server. Death drops your things (issue #19, ported from The Counter Earth).
On a player's death (config-gated): snapshot-and-clear the inventory — and, by default, WORN
equipment too — via `Inventory.clearAll`, then spill it into an anchored loot bag at the death
spot (ground-clamped raycast). The bag holds its contents as IntValue children (item id → qty),
shows a floating countdown, and despawns after `LifetimeSeconds` with whatever's left. ANYONE
may loot it: equipment restores to empty equip slots FIRST (a satchel re-grows your slots and
weight cap before the stacks pour back in), then stacks grant via `Inventory.addUpTo` partial
pickups leave the remainder in the bag, so nothing is ever silently lost.
Fires `player:died` (Hooks + EventBridge AFTER the clear, so consumers see consistent state)
plus `lootbag:dropped` / `lootbag:collected`, and maps `player:died` into the Progression
stream ("death") so `deaths_total` counters/achievements work. The owner gets a `LootBagDropped`
remote for the client-side beacon. Tuning: the "LootBags" Config section. Session-scoped.
Started by SurvivorCore.start().
]]
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.LootBags is server-only — booted by SurvivorCore.start()")
local Inventory = require(script.Parent.Inventory)
local Progression = require(script.Parent.Progression)
local Registries = require(script.Parent.Parent.registries)
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 LootBagsConfig = require(script.Parent.Parent.shared.LootBagsConfig)
local LootBags = {}
local started = false
local looting: { [Player]: boolean } = {} -- re-entry guard per collector
-- ── Bag construction ─────────────────────────────────────────────────────────
local function bagTemplate(): PVInstance?
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
local tmpl = content and content:FindFirstChild("LootBag")
if tmpl and tmpl:IsA("PVInstance") then
return tmpl
end
return nil
end
local function buildPlaceholderBag(): BasePart
local bag = Instance.new("Part")
bag.Name = "LootBag"
bag.Shape = Enum.PartType.Ball
bag.Size = Vector3.new(1.6, 1.6, 1.6)
bag.Color = Color3.fromRGB(120, 90, 55)
bag.Material = Enum.Material.Fabric
return bag
end
local function bagHost(bag: Instance): BasePart?
if bag:IsA("BasePart") then
return bag
end
return bag:FindFirstChildWhichIsA("BasePart")
end
-- Floating countdown label (server-built, HP-bar pattern — everyone sees it).
local function attachCountdown(bag: Instance, lifetime: number)
local host = bagHost(bag)
if not host then
return
end
local gui = Instance.new("BillboardGui")
gui.Name = "_LootBagTimer"
gui.Adornee = host
gui.Size = UDim2.fromOffset(90, 22)
gui.StudsOffset = Vector3.new(0, 2.2, 0)
gui.AlwaysOnTop = true
gui.MaxDistance = 80
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.TextColor3 = Color3.fromRGB(245, 245, 245)
label.Font = Enum.Font.GothamBold
label.TextSize = 13
label.Parent = gui
gui.Parent = bag
task.spawn(function()
local deadline = os.clock() + lifetime
while bag.Parent and gui.Parent do
local left = math.max(0, math.floor(deadline - os.clock()))
label.Text = string.format("%d:%02d", left // 60, left % 60)
label.TextColor3 = if left < 30
then Color3.fromRGB(235, 90, 90)
elseif left < 60 then Color3.fromRGB(240, 190, 80)
else Color3.fromRGB(245, 245, 245)
if left <= 0 then
break
end
task.wait(1)
end
end)
end
-- ── Pickup ───────────────────────────────────────────────────────────────────
-- Grant the bag's contents to a collector: equipment first (Back first, so a satchel re-grows the
-- slot/weight caps), then stacks up-to-fit. Consumed IntValues shrink/vanish; the bag survives
-- with any remainder and is destroyed only when empty.
local function collect(bag: Instance, player: Player)
if looting[player] then
return
end
looting[player] = true
local entries = {}
for _, child in bag:GetChildren() do
if child:IsA("IntValue") and child.Value > 0 then
table.insert(entries, child)
end
end
-- Equipment-restorable entries first, "back" slot before the rest.
table.sort(entries, function(a, b)
local da = Registries.Items.get(a.Name)
local db = Registries.Items.get(b.Name)
local ea = (da and typeof(da.equipment) == "table" and da.equipment.slot) or nil
local eb = (db and typeof(db.equipment) == "table" and db.equipment.slot) or nil
if (ea ~= nil) ~= (eb ~= nil) then
return ea ~= nil
end
if ea and eb and (ea == "back") ~= (eb == "back") then
return ea == "back"
end
return a.Name < b.Name
end)
local grantedAny = false
for _, entry in entries do
local def = Registries.Items.get(entry.Name)
-- One unit may go straight onto an empty equip slot (the satchel path).
if def and typeof(def.equipment) == "table" and entry.Value > 0 then
if Inventory.restoreEquip(player, def.equipment.slot, entry.Name) then
entry.Value -= 1
grantedAny = true
end
end
if entry.Value > 0 then
local granted = Inventory.addUpTo(player, entry.Name, entry.Value)
if granted > 0 then
entry.Value -= granted
grantedAny = true
end
end
if entry.Value <= 0 then
entry:Destroy()
end
end
if grantedAny then
local remaining = 0
for _, child in bag:GetChildren() do
if child:IsA("IntValue") and child.Value > 0 then
remaining += 1
end
end
local ctx = { player = player, bag = bag, emptied = remaining == 0 }
Hooks.run("lootbag:collected", ctx)
EventBridge.fire("lootbag:collected", player, { emptied = remaining == 0 })
if remaining == 0 then
bag:Destroy()
end
end
looting[player] = nil
end
-- ── Death → bag ──────────────────────────────────────────────────────────────
local function dropBag(player: Player, position: Vector3)
local cfg = LootBagsConfig.get()
local snapshot = Inventory.clearAll(player, { equipment = cfg.DropEquipment ~= false })
-- player:died fires AFTER the clear so hook/bus consumers (deaths counters, analytics) see
-- consistent state — the inventory is already empty by the time anyone reacts.
local dctx = { player = player, position = position }
Hooks.run("player:died", dctx)
EventBridge.fire("player:died", player, { position = position })
local total = #snapshot.slots + #snapshot.equipment
if total == 0 then
return -- died empty-handed; no bag
end
local tmpl = bagTemplate()
local bag: Instance = if tmpl then tmpl:Clone() else buildPlaceholderBag()
bag.Name = "LootBag"
-- Aggregate contents as IntValue children (item id → qty), equipment included.
local counts: { [string]: number } = {}
for _, s in snapshot.slots do
counts[s.itemId] = (counts[s.itemId] or 0) + s.qty
end
for _, e in snapshot.equipment do
counts[e.itemId] = (counts[e.itemId] or 0) + 1
end
for itemId, qty in counts do
local v = Instance.new("IntValue")
v.Name = itemId
v.Value = qty
v.Parent = bag
end
-- Ground-clamp: a mid-air/water death still leaves a reachable, ANCHORED bag.
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { player.Character :: any, bag }
local hit = Workspace:Raycast(position + Vector3.new(0, 4, 0), Vector3.new(0, -120, 0), params)
local groundY = if hit then hit.Position.Y else position.Y;
(bag :: PVInstance):PivotTo(CFrame.new(position.X, groundY + 1, position.Z))
for _, p in bag:GetDescendants() do
if p:IsA("BasePart") then
p.Anchored = true
p.CanCollide = false
end
end
local host = bagHost(bag)
if host then
host.Anchored = true
host.CanCollide = false
end
bag:SetAttribute("_OwnerUserId", player.UserId)
bag:SetAttribute("_SpawnTime", os.time())
bag.Parent = Workspace
local lifetime = tonumber(cfg.LifetimeSeconds) or 300
if cfg.ShowCountdown ~= false then
attachCountdown(bag, lifetime)
end
if host then
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Loot"
prompt.ObjectText = player.Name .. "'s bag"
prompt.MaxActivationDistance = tonumber(cfg.InteractRange) or 8
prompt.HoldDuration = 0
prompt.RequiresLineOfSight = false
prompt.Parent = host
prompt.Triggered:Connect(function(collector)
collect(bag, collector)
end)
end
task.delay(lifetime, function()
if bag.Parent then
bag:Destroy()
end
end)
local ctx = { player = player, bag = bag, position = position, items = total }
Hooks.run("lootbag:dropped", ctx)
EventBridge.fire("lootbag:dropped", player, { position = position, items = total })
-- Owner-only client juice: beacon + "your things dropped" toast.
if cfg.OwnerBeacon ~= false then
Remotes.event("LootBagDropped"):FireClient(player, bag, lifetime)
end
Remotes.event("Notify"):FireClient(player, {
kind = "death",
title = "You died",
body = "Your belongings dropped where you fell.",
})
end
local function hookCharacter(player: Player, character: Model)
local humanoid = character:WaitForChild("Humanoid", 10)
if not humanoid or not humanoid:IsA("Humanoid") then
return
end
humanoid.Died:Once(function()
local root = character:FindFirstChild("HumanoidRootPart")
local position = root and root.Position or character:GetPivot().Position
if LootBagsConfig.get().Enabled == false then
-- Still announce the death for counters/analytics; inventory untouched.
Hooks.run("player:died", { player = player, position = position })
EventBridge.fire("player:died", player, { position = position })
return
end
local ok, err = pcall(dropBag, player, position)
if not ok then
warn(`[SurvivorCore.LootBags] drop failed for {player.Name}: {tostring(err)}`)
end
end)
end
local function watchPlayer(player: Player)
player.CharacterAdded:Connect(function(character)
hookCharacter(player, character)
end)
if player.Character then
task.spawn(hookCharacter, player, player.Character)
end
end
function LootBags.start(_options: { [string]: any }?)
if started then
return
end
started = true
-- Deaths flow into the shared progress stream → deaths_total counters/achievements.
Progression.map("player:died", function(_player, _data)
return "death", nil, 1
end)
Remotes.event("LootBagDropped") -- eager so clients can connect at startup
for _, player in Players:GetPlayers() do
watchPlayer(player)
end
Players.PlayerAdded:Connect(watchPlayer)
Players.PlayerRemoving:Connect(function(player)
looting[player] = nil
end)
end
return LootBags
+94
View File
@@ -82,6 +82,15 @@ local function resolveMob(model: Model)
attackDamage = num("AttackDamage", "attackDamage", "DefaultAttackDamage"),
attackCooldown = num("AttackCooldown", "attackCooldown", "DefaultAttackCooldown"),
wanderRadius = num("WanderRadius", "wanderRadius", "DefaultWanderRadius"),
-- Carcass (hunting/butchering): blank carcassItem = no carcass on death.
carcassItem = tostring(attrOr(model, "CarcassItem") or def.carcassItem or ""),
carcassHp = math.floor(tonumber(attrOr(model, "CarcassHp")) or tonumber(def.carcassHp) or 3),
carcassTool = tostring(attrOr(model, "CarcassTool") or def.carcassTool or ""),
carcassYieldMin = math.floor(tonumber(attrOr(model, "CarcassYieldMin")) or tonumber(def.carcassYieldMin) or 1),
carcassYieldMax = math.floor(tonumber(attrOr(model, "CarcassYieldMax")) or tonumber(def.carcassYieldMax) or 1),
carcassSeconds = tonumber(attrOr(model, "CarcassSeconds")) or tonumber(def.carcassSeconds) or tonumber(
cfg.DefaultCarcassSeconds
) or 120,
}
end
@@ -279,6 +288,77 @@ local function step(s: any)
end
end
-- Hunting/butchering (#13): a slain mob whose def sets carcassItem leaves a CARCASS — a tagged
-- `Gatherable` node, so butchering reuses the whole harvesting pipeline (knife gate, per-hit
-- yields, HP bar, gather:* hooks, counters). Reactions key on "<mobType>_carcass".
local function spawnCarcass(s: any, position: Vector3)
-- Prefer a creator template; strip the clone of any bound/stashed state (a template saved from
-- a live Gatherable would otherwise never bind, or spawn half-butchered).
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local content = ReplicatedStorage:FindFirstChild("SurvivorCoreContent")
local templates = content and content:FindFirstChild("Carcasses")
local template = templates and templates:FindFirstChild(s.mobType)
local carcass: Instance
if template and template:IsA("PVInstance") then
carcass = template:Clone()
carcass:SetAttribute("_scBound", nil)
for attr in carcass:GetAttributes() do
if string.sub(attr, 1, 1) == "_" then
carcass:SetAttribute(attr, nil)
end
end
else
local part = Instance.new("Part")
part.Name = s.mobType .. "_carcass"
part.Size = Vector3.new(3, 1, 1.6) -- a low, lying body shape
part.Color = Color3.fromRGB(115, 62, 52)
part.Material = Enum.Material.SmoothPlastic
carcass = part
end
-- Rest the carcass on the ground below the death position (mid-air deaths clamp down).
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { s.model, carcass }
local hit = Workspace:Raycast(position + Vector3.new(0, 2, 0), Vector3.new(0, -60, 0), params)
local groundY = if hit then hit.Position.Y else position.Y
local height = if carcass:IsA("BasePart") then carcass.Size.Y else 1
(carcass :: PVInstance):PivotTo(CFrame.new(position.X, groundY + height / 2 + 0.05, position.Z))
for _, p in carcass:GetDescendants() do
if p:IsA("BasePart") then
p.Anchored = true
end
end
if carcass:IsA("BasePart") then
carcass.Anchored = true
end
-- ALL attributes before the tag — the Gatherable component binds synchronously on tag-added.
carcass:SetAttribute("Resource", s.mobType .. "_carcass") -- reaction/prompt key (no def needed)
carcass:SetAttribute("ItemId", s.carcassItem)
carcass:SetAttribute("HP", s.carcassHp)
carcass:SetAttribute("RequireTool", s.carcassTool)
carcass:SetAttribute("YieldMin", s.carcassYieldMin)
carcass:SetAttribute("YieldMax", s.carcassYieldMax)
carcass:SetAttribute("Interaction", "prompt")
carcass:SetAttribute("PromptText", "Butcher")
carcass:SetAttribute("PromptObject", s.mobType .. " carcass")
carcass:SetAttribute("DestroyOnDeplete", true)
CollectionService:AddTag(carcass, "Gatherable")
-- NEVER parent to the mob model — the CorpseSeconds cleanup would destroy it too.
carcass.Parent = Workspace
if s.carcassSeconds > 0 then
task.delay(s.carcassSeconds, function()
if carcass.Parent then
carcass:Destroy()
end
end)
end
return carcass
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)
@@ -300,6 +380,14 @@ local function onDied(s: any)
Reactions.run(s.mobType, "died", ctx)
EventBridge.fire("mob:died", s.lastDamageBy, { mob = s.model, mobType = s.mobType })
-- Leave a butcherable carcass (hunting) when the def asks for one.
if s.carcassItem ~= "" and ctx.position then
local ok, err = pcall(spawnCarcass, s, ctx.position)
if not ok then
warn(`[SurvivorCore.Mobs] carcass spawn failed for '{s.mobType}': {tostring(err)}`)
end
end
local corpse = tonumber(MobsConfig.get().CorpseSeconds) or 5
local model = s.model
task.delay(corpse, function()
@@ -376,6 +464,12 @@ function Mobs.adopt(model: Model): any?
attackDamage = r.attackDamage,
attackCooldown = r.attackCooldown,
wanderRadius = r.wanderRadius,
carcassItem = r.carcassItem,
carcassHp = r.carcassHp,
carcassTool = r.carcassTool,
carcassYieldMin = r.carcassYieldMin,
carcassYieldMax = r.carcassYieldMax,
carcassSeconds = r.carcassSeconds,
requireLoS = MobsConfig.get().RequireLineOfSight ~= false,
spawnPos = root.Position,
spawnCFrame = model:GetPivot(),
+3
View File
@@ -62,6 +62,9 @@ local function makeTool(def: any, itemId: string): Tool
end
end
tool.Name = def.name or itemId
-- Engine tools must never drop as world pickups (death would strand a stray Tool otherwise) —
-- enforce on creator templates too, which often ship with Roblox's CanBeDropped=true default.
tool.CanBeDropped = false
tool:SetAttribute(TOOL_MARKER, true)
tool:SetAttribute("_ItemId", itemId)
tool:SetAttribute("ToolType", def.toolType)