mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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:
co-authored by
Claude Opus 4.8
parent
991d1b0d80
commit
9951e09a3f
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 & 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 & AI</h3>
|
||||
<p>A shared FSM substrate: hostile mobs chase & attack, passive animals flee. Behavior is data — faction picks the profile.</p>
|
||||
<h3>Mobs, AI & hunting</h3>
|
||||
<p>Hostile mobs chase & 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">
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user