diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3b0cb..fe2ca20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/demo/server/Boot.server.luau b/demo/server/Boot.server.luau index 0d5d6f8..c3b75a8 100644 --- a/demo/server/Boot.server.luau +++ b/demo/server/Boot.server.luau @@ -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 = "" 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) diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md index 2cd65bb..5e6c94b 100644 --- a/docs/admin-plugin.md +++ b/docs/admin-plugin.md @@ -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 diff --git a/docs/combat.md b/docs/combat.md new file mode 100644 index 0000000..758cc80 --- /dev/null +++ b/docs/combat.md @@ -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). diff --git a/docs/content-authoring.md b/docs/content-authoring.md index 4b1f589..ceddb05 100644 --- a/docs/content-authoring.md +++ b/docs/content-authoring.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, diff --git a/docs/mobs.md b/docs/mobs.md new file mode 100644 index 0000000..d429368 --- /dev/null +++ b/docs/mobs.md @@ -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.`) 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). diff --git a/plugin/ContentAdmin.luau b/plugin/ContentAdmin.luau index 8ce34bb..1ef04e3 100644 --- a/plugin/ContentAdmin.luau +++ b/plugin/ContentAdmin.luau @@ -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 diff --git a/plugin/ContentAdminUi.luau b/plugin/ContentAdminUi.luau index 7aef214..ef7a1f4 100644 --- a/plugin/ContentAdminUi.luau +++ b/plugin/ContentAdminUi.luau @@ -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 diff --git a/plugin/init.server.luau b/plugin/init.server.luau index f81d5c5..860b0b4 100644 --- a/plugin/init.server.luau +++ b/plugin/init.server.luau @@ -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 diff --git a/src/client/CombatInput.luau b/src/client/CombatInput.luau new file mode 100644 index 0000000..8124c21 --- /dev/null +++ b/src/client/CombatInput.luau @@ -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 diff --git a/src/client/ToolHarvest.luau b/src/client/ToolHarvest.luau index 94d91fd..a9afe9c 100644 --- a/src/client/ToolHarvest.luau +++ b/src/client/ToolHarvest.luau @@ -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 diff --git a/src/components/Mob.luau b/src/components/Mob.luau new file mode 100644 index 0000000..a7a8afc --- /dev/null +++ b/src/components/Mob.luau @@ -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 = ""` 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, +}) diff --git a/src/foundation/Hooks.luau b/src/foundation/Hooks.luau index 6106304..0c815bf 100644 --- a/src/foundation/Hooks.luau +++ b/src/foundation/Hooks.luau @@ -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 = {} diff --git a/src/init.luau b/src/init.luau index 4433705..853fdf9 100644 --- a/src/init.luau +++ b/src/init.luau @@ -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() diff --git a/src/shared/CombatConfig.luau b/src/shared/CombatConfig.luau new file mode 100644 index 0000000..f4bc00a --- /dev/null +++ b/src/shared/CombatConfig.luau @@ -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 diff --git a/src/shared/MobsConfig.luau b/src/shared/MobsConfig.luau new file mode 100644 index 0000000..4d2d633 --- /dev/null +++ b/src/shared/MobsConfig.luau @@ -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 diff --git a/src/systems/Combat.luau b/src/systems/Combat.luau new file mode 100644 index 0000000..200a33c --- /dev/null +++ b/src/systems/Combat.luau @@ -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 diff --git a/src/systems/Inventory.luau b/src/systems/Inventory.luau index 5bd32b2..7e892d9 100644 --- a/src/systems/Inventory.luau +++ b/src/systems/Inventory.luau @@ -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 diff --git a/src/systems/Mobs.luau b/src/systems/Mobs.luau new file mode 100644 index 0000000..ab99184 --- /dev/null +++ b/src/systems/Mobs.luau @@ -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.. +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.) 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 diff --git a/src/systems/ToolEquip.luau b/src/systems/ToolEquip.luau index 29c9ed1..2f60baa 100644 --- a/src/systems/ToolEquip.luau +++ b/src/systems/ToolEquip.luau @@ -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 diff --git a/wally.toml b/wally.toml index a02e2b6..b45cbaa 100644 --- a/wally.toml +++ b/wally.toml @@ -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"