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