roblox-opencode 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -122
- package/commands/setup-game.md +108 -108
- package/commands/sync-check.md +53 -53
- package/core/roblox-core.md +93 -93
- package/dist/server.js +189 -167
- package/package.json +35 -35
- package/skills/roblox-analytics/SKILL.md +277 -277
- package/skills/roblox-analytics/references/event-batcher.luau +75 -75
- package/skills/roblox-animation-vfx/SKILL.md +1325 -1325
- package/skills/roblox-architecture/SKILL.md +877 -863
- package/skills/roblox-architecture/references/combat-systems.md +1381 -1381
- package/skills/roblox-code-review/SKILL.md +686 -686
- package/skills/roblox-data/SKILL.md +889 -889
- package/skills/roblox-data/references/inventory-systems.md +1729 -1729
- package/skills/roblox-debug/SKILL.md +98 -98
- package/skills/roblox-gui/SKILL.md +1103 -1103
- package/skills/roblox-gui-fusion/SKILL.md +150 -150
- package/skills/roblox-gui-fusion/references/inventory.luau +427 -427
- package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -579
- package/skills/roblox-gui-fusion/references/shop.luau +411 -411
- package/skills/roblox-luau-mastery/SKILL.md +1618 -1519
- package/skills/roblox-monetization/SKILL.md +1084 -1084
- package/skills/roblox-monetization/references/process-receipt.luau +131 -131
- package/skills/roblox-networking/SKILL.md +669 -669
- package/skills/roblox-networking/references/remote-validator.luau +193 -193
- package/skills/roblox-publish-checklist/SKILL.md +127 -127
- package/skills/roblox-runtime/SKILL.md +753 -753
- package/skills/roblox-sharp-edges/SKILL.md +294 -294
- package/skills/roblox-sync/SKILL.md +126 -126
- package/skills/roblox-testing/SKILL.md +943 -943
- package/skills/roblox-tooling/SKILL.md +149 -149
- package/vendor/LICENSES/ProfileStore-LICENSE +201 -201
- package/vendor/LICENSES/RbxUtil-LICENSE +7 -7
- package/vendor/LICENSES/promise-LICENSE +20 -20
- package/vendor/LICENSES/t-LICENSE +21 -21
- package/vendor/LICENSES/testez-LICENSE +200 -200
- package/vendor/README.md +83 -83
- package/vendor/fusion/Animation/ExternalTime.luau +83 -83
- package/vendor/fusion/Animation/Spring.luau +321 -321
- package/vendor/fusion/Animation/Stopwatch.luau +127 -127
- package/vendor/fusion/Animation/Tween.luau +187 -187
- package/vendor/fusion/Animation/getTweenDuration.luau +27 -27
- package/vendor/fusion/Animation/getTweenRatio.luau +47 -47
- package/vendor/fusion/Animation/lerpType.luau +163 -163
- package/vendor/fusion/Animation/packType.luau +99 -99
- package/vendor/fusion/Animation/springCoefficients.luau +80 -80
- package/vendor/fusion/Animation/unpackType.luau +102 -102
- package/vendor/fusion/Colour/Oklab.luau +70 -70
- package/vendor/fusion/Colour/sRGB.luau +54 -54
- package/vendor/fusion/External.luau +167 -167
- package/vendor/fusion/ExternalDebug.luau +69 -69
- package/vendor/fusion/Graph/Observer.luau +113 -113
- package/vendor/fusion/Graph/castToGraph.luau +28 -28
- package/vendor/fusion/Graph/change.luau +80 -80
- package/vendor/fusion/Graph/depend.luau +32 -32
- package/vendor/fusion/Graph/evaluate.luau +55 -55
- package/vendor/fusion/Instances/Attribute.luau +57 -57
- package/vendor/fusion/Instances/AttributeChange.luau +46 -46
- package/vendor/fusion/Instances/AttributeOut.luau +63 -63
- package/vendor/fusion/Instances/Child.luau +21 -21
- package/vendor/fusion/Instances/Children.luau +147 -147
- package/vendor/fusion/Instances/Hydrate.luau +32 -32
- package/vendor/fusion/Instances/New.luau +52 -52
- package/vendor/fusion/Instances/OnChange.luau +49 -49
- package/vendor/fusion/Instances/OnEvent.luau +53 -53
- package/vendor/fusion/Instances/Out.luau +69 -69
- package/vendor/fusion/Instances/applyInstanceProps.luau +148 -148
- package/vendor/fusion/Instances/defaultProps.luau +194 -194
- package/vendor/fusion/LICENSE +21 -21
- package/vendor/fusion/Logging/formatError.luau +48 -48
- package/vendor/fusion/Logging/messages.luau +51 -51
- package/vendor/fusion/Logging/parseError.luau +24 -24
- package/vendor/fusion/Memory/checkLifetime.luau +133 -133
- package/vendor/fusion/Memory/deriveScope.luau +23 -23
- package/vendor/fusion/Memory/deriveScopeImpl.luau +44 -44
- package/vendor/fusion/Memory/doCleanup.luau +78 -78
- package/vendor/fusion/Memory/innerScope.luau +33 -33
- package/vendor/fusion/Memory/legacyCleanup.luau +17 -17
- package/vendor/fusion/Memory/needsDestruction.luau +16 -16
- package/vendor/fusion/Memory/poisonScope.luau +33 -33
- package/vendor/fusion/Memory/scopePool.luau +54 -54
- package/vendor/fusion/Memory/scoped.luau +26 -26
- package/vendor/fusion/Memory/whichLivesLonger.luau +74 -74
- package/vendor/fusion/RobloxExternal.luau +97 -97
- package/vendor/fusion/State/Computed.luau +138 -138
- package/vendor/fusion/State/For/Disassembly.luau +210 -210
- package/vendor/fusion/State/For/ForTypes.luau +30 -30
- package/vendor/fusion/State/For/init.luau +109 -109
- package/vendor/fusion/State/ForKeys.luau +93 -93
- package/vendor/fusion/State/ForPairs.luau +96 -96
- package/vendor/fusion/State/ForValues.luau +93 -93
- package/vendor/fusion/State/Value.luau +87 -87
- package/vendor/fusion/State/castToState.luau +25 -25
- package/vendor/fusion/State/peek.luau +30 -30
- package/vendor/fusion/Types.luau +314 -314
- package/vendor/fusion/Utility/Contextual.luau +90 -90
- package/vendor/fusion/Utility/Safe.luau +22 -22
- package/vendor/fusion/Utility/isSimilar.luau +29 -29
- package/vendor/fusion/Utility/merge.luau +35 -35
- package/vendor/fusion/Utility/nameOf.luau +34 -34
- package/vendor/fusion/Utility/never.luau +13 -13
- package/vendor/fusion/Utility/nicknames.luau +10 -10
- package/vendor/fusion/Utility/xtypeof.luau +26 -26
- package/vendor/fusion/init.luau +82 -82
- package/vendor/profilestore/init.luau +2242 -2242
- package/vendor/promise/init.luau +1982 -1982
- package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -25
- package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -228
- package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -269
- package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -223
- package/vendor/rbxutil/buffer-util/Types.luau +60 -60
- package/vendor/rbxutil/buffer-util/index.d.ts +153 -153
- package/vendor/rbxutil/buffer-util/init.luau +41 -41
- package/vendor/rbxutil/buffer-util/package.json +16 -16
- package/vendor/rbxutil/buffer-util/wally.toml +9 -9
- package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -232
- package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -156
- package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -109
- package/vendor/rbxutil/comm/Client/init.luau +135 -135
- package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -295
- package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -211
- package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -211
- package/vendor/rbxutil/comm/Server/init.luau +140 -140
- package/vendor/rbxutil/comm/Types.luau +18 -18
- package/vendor/rbxutil/comm/Util.luau +27 -27
- package/vendor/rbxutil/comm/init.luau +35 -35
- package/vendor/rbxutil/comm/wally.toml +13 -13
- package/vendor/rbxutil/component/init.luau +759 -759
- package/vendor/rbxutil/component/init.test.luau +311 -311
- package/vendor/rbxutil/component/wally.toml +14 -14
- package/vendor/rbxutil/concur/init.luau +542 -542
- package/vendor/rbxutil/concur/init.test.luau +364 -364
- package/vendor/rbxutil/concur/wally.toml +8 -8
- package/vendor/rbxutil/enum-list/init.luau +101 -101
- package/vendor/rbxutil/enum-list/init.test.luau +91 -91
- package/vendor/rbxutil/enum-list/wally.toml +8 -8
- package/vendor/rbxutil/find/index.d.ts +20 -20
- package/vendor/rbxutil/find/init.luau +44 -44
- package/vendor/rbxutil/find/package.json +17 -17
- package/vendor/rbxutil/find/wally.toml +8 -8
- package/vendor/rbxutil/input/Gamepad.luau +559 -559
- package/vendor/rbxutil/input/Keyboard.luau +124 -124
- package/vendor/rbxutil/input/Mouse.luau +278 -278
- package/vendor/rbxutil/input/PreferredInput.luau +91 -91
- package/vendor/rbxutil/input/Touch.luau +120 -120
- package/vendor/rbxutil/input/init.luau +33 -33
- package/vendor/rbxutil/input/wally.toml +12 -12
- package/vendor/rbxutil/loader/index.d.ts +15 -15
- package/vendor/rbxutil/loader/init.luau +137 -137
- package/vendor/rbxutil/loader/wally.toml +8 -8
- package/vendor/rbxutil/log/index.d.ts +38 -38
- package/vendor/rbxutil/log/init.luau +746 -746
- package/vendor/rbxutil/log/wally.toml +8 -8
- package/vendor/rbxutil/net/init.luau +190 -190
- package/vendor/rbxutil/net/wally.toml +8 -8
- package/vendor/rbxutil/option/index.d.ts +44 -44
- package/vendor/rbxutil/option/init.luau +489 -489
- package/vendor/rbxutil/option/init.test.luau +342 -342
- package/vendor/rbxutil/option/wally.toml +8 -8
- package/vendor/rbxutil/pid/index.d.ts +53 -53
- package/vendor/rbxutil/pid/init.luau +195 -195
- package/vendor/rbxutil/pid/package.json +16 -16
- package/vendor/rbxutil/pid/wally.toml +9 -9
- package/vendor/rbxutil/quaternion/index.d.ts +117 -117
- package/vendor/rbxutil/quaternion/init.luau +570 -570
- package/vendor/rbxutil/quaternion/package.json +16 -16
- package/vendor/rbxutil/quaternion/wally.toml +9 -9
- package/vendor/rbxutil/query/index.d.ts +43 -43
- package/vendor/rbxutil/query/init.luau +117 -117
- package/vendor/rbxutil/query/package.json +18 -18
- package/vendor/rbxutil/query/wally.toml +9 -9
- package/vendor/rbxutil/sequent/index.d.ts +28 -28
- package/vendor/rbxutil/sequent/init.luau +340 -340
- package/vendor/rbxutil/sequent/package.json +16 -16
- package/vendor/rbxutil/sequent/wally.toml +9 -9
- package/vendor/rbxutil/ser/init.luau +175 -175
- package/vendor/rbxutil/ser/init.test.luau +50 -50
- package/vendor/rbxutil/ser/wally.toml +11 -11
- package/vendor/rbxutil/shake/index.d.ts +36 -36
- package/vendor/rbxutil/shake/init.luau +532 -532
- package/vendor/rbxutil/shake/init.test.luau +267 -267
- package/vendor/rbxutil/shake/package.json +16 -16
- package/vendor/rbxutil/shake/wally.toml +9 -9
- package/vendor/rbxutil/signal/index.d.ts +100 -100
- package/vendor/rbxutil/signal/init.luau +432 -432
- package/vendor/rbxutil/signal/init.test.luau +190 -190
- package/vendor/rbxutil/signal/package.json +17 -17
- package/vendor/rbxutil/signal/wally.toml +9 -9
- package/vendor/rbxutil/silo/TableWatcher.luau +65 -65
- package/vendor/rbxutil/silo/Util.luau +55 -55
- package/vendor/rbxutil/silo/init.luau +338 -338
- package/vendor/rbxutil/silo/init.test.luau +215 -215
- package/vendor/rbxutil/silo/wally.toml +8 -8
- package/vendor/rbxutil/spring/index.d.ts +40 -40
- package/vendor/rbxutil/spring/init.luau +97 -97
- package/vendor/rbxutil/spring/package.json +17 -17
- package/vendor/rbxutil/spring/wally.toml +8 -8
- package/vendor/rbxutil/stream/index.d.ts +88 -88
- package/vendor/rbxutil/stream/init.luau +597 -597
- package/vendor/rbxutil/stream/package.json +18 -18
- package/vendor/rbxutil/stream/wally.toml +9 -9
- package/vendor/rbxutil/streamable/Streamable.luau +202 -202
- package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -80
- package/vendor/rbxutil/streamable/init.luau +8 -8
- package/vendor/rbxutil/streamable/wally.toml +12 -12
- package/vendor/rbxutil/symbol/init.luau +56 -56
- package/vendor/rbxutil/symbol/init.test.luau +37 -37
- package/vendor/rbxutil/symbol/wally.toml +8 -8
- package/vendor/rbxutil/table-util/init.luau +938 -938
- package/vendor/rbxutil/table-util/init.test.luau +439 -439
- package/vendor/rbxutil/task-queue/index.d.ts +27 -27
- package/vendor/rbxutil/task-queue/init.luau +97 -97
- package/vendor/rbxutil/task-queue/wally.toml +8 -8
- package/vendor/rbxutil/timer/index.d.ts +81 -81
- package/vendor/rbxutil/timer/init.luau +249 -249
- package/vendor/rbxutil/timer/init.test.luau +73 -73
- package/vendor/rbxutil/timer/wally.toml +11 -11
- package/vendor/rbxutil/tree/index.d.ts +15 -15
- package/vendor/rbxutil/tree/init.luau +137 -137
- package/vendor/rbxutil/tree/wally.toml +8 -8
- package/vendor/rbxutil/trove/index.d.ts +46 -46
- package/vendor/rbxutil/trove/init.luau +787 -787
- package/vendor/rbxutil/trove/init.test.luau +203 -203
- package/vendor/rbxutil/trove/wally.toml +8 -8
- package/vendor/rbxutil/typed-remote/init.luau +196 -196
- package/vendor/rbxutil/typed-remote/wally.toml +8 -8
- package/vendor/rbxutil/wait-for/index.d.ts +17 -17
- package/vendor/rbxutil/wait-for/init.luau +257 -257
- package/vendor/rbxutil/wait-for/init.test.luau +182 -182
- package/vendor/rbxutil/wait-for/wally.toml +11 -11
- package/vendor/t/t.lua +1350 -1350
- package/vendor/testez/Context.lua +26 -26
- package/vendor/testez/Expectation.lua +311 -311
- package/vendor/testez/ExpectationContext.lua +38 -38
- package/vendor/testez/LifecycleHooks.lua +89 -89
- package/vendor/testez/Reporters/TeamCityReporter.lua +101 -101
- package/vendor/testez/Reporters/TextReporter.lua +105 -105
- package/vendor/testez/Reporters/TextReporterQuiet.lua +96 -96
- package/vendor/testez/TestBootstrap.lua +146 -146
- package/vendor/testez/TestEnum.lua +27 -27
- package/vendor/testez/TestPlan.lua +304 -304
- package/vendor/testez/TestPlanner.lua +39 -39
- package/vendor/testez/TestResults.lua +111 -111
- package/vendor/testez/TestRunner.lua +188 -188
- package/vendor/testez/TestSession.lua +243 -243
- package/vendor/testez/init.lua +39 -39
|
@@ -1,1381 +1,1381 @@
|
|
|
1
|
-
# Combat Systems Reference
|
|
2
|
-
|
|
3
|
-
> **Load when:** Building combat, weapons, PvP, PvE, damage systems, hitboxes, melee/ranged attacks, combo systems, blocking/parrying, cooldowns, or skill-based combat.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## 1. Overview
|
|
8
|
-
|
|
9
|
-
Combat is one of the most exploited systems in Roblox. Every design decision must start from the principle: **the server is the sole authority on damage, health, and combat state**. The client's job is to send *intent* ("I pressed attack"), show responsive animations/VFX, and display server-confirmed results.
|
|
10
|
-
|
|
11
|
-
This reference covers:
|
|
12
|
-
|
|
13
|
-
- Server-authoritative architecture
|
|
14
|
-
- Hitbox detection (spatial queries and raycasts)
|
|
15
|
-
- Combat state machines
|
|
16
|
-
- Damage calculation formulas
|
|
17
|
-
- Melee and ranged combat patterns
|
|
18
|
-
- Cooldown enforcement
|
|
19
|
-
- Anti-exploit hardening
|
|
20
|
-
|
|
21
|
-
All code examples are production-grade Luau. Adapt to your game's scale.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## 2. Combat Architecture
|
|
26
|
-
|
|
27
|
-
### The Golden Rule
|
|
28
|
-
|
|
29
|
-
> The client sends **intent**. The server **validates and applies**.
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
Client Server
|
|
33
|
-
------ ------
|
|
34
|
-
Player presses attack
|
|
35
|
-
|
|
|
36
|
-
+--> FireServer("Attack")
|
|
37
|
-
|
|
|
38
|
-
+--> Validate cooldown
|
|
39
|
-
+--> Validate state (not stunned, not dead)
|
|
40
|
-
+--> Run hitbox detection
|
|
41
|
-
+--> Calculate damage
|
|
42
|
-
+--> Apply damage to targets
|
|
43
|
-
+--> Update attacker state/cooldowns
|
|
44
|
-
|
|
|
45
|
-
<-- FireClient(results) ------+
|
|
46
|
-
|
|
|
47
|
-
+--> Play hit VFX/SFX
|
|
48
|
-
+--> Show damage numbers
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
### Why Not Client-Authoritative?
|
|
52
|
-
|
|
53
|
-
Exploiters can modify LocalScripts, fire RemoteEvents with fabricated data, and teleport their character. If the client decides who gets hit or how much damage to deal, a single exploiter ruins the entire server.
|
|
54
|
-
|
|
55
|
-
### Latency Compensation
|
|
56
|
-
|
|
57
|
-
For fast-paced combat, pure server-side hitboxes can feel unresponsive. Two strategies:
|
|
58
|
-
|
|
59
|
-
1. **Predictive VFX** -- Client plays the swing animation and VFX immediately on input. Server confirms the hit separately. If the server says "miss," the client shows no damage numbers. Players perceive responsiveness from the animation even if the server takes 50-100ms to confirm.
|
|
60
|
-
|
|
61
|
-
2. **Client hint with server validation** -- Client sends its estimated hit targets along with the attack request. Server re-runs the hitbox check using the character's server-side position. If the server's check agrees, damage applies. If not, the client hint is discarded. This catches teleport exploits while tolerating minor positional lag.
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
|
|
65
|
-
## 3. Hitbox Detection
|
|
66
|
-
|
|
67
|
-
### Area Detection (Melee / AoE)
|
|
68
|
-
|
|
69
|
-
Use `workspace:GetPartBoundsInBox()` for volume-based detection:
|
|
70
|
-
|
|
71
|
-
```luau
|
|
72
|
-
local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model}
|
|
73
|
-
local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2)
|
|
74
|
-
local size = Vector3.new(width, height, range)
|
|
75
|
-
|
|
76
|
-
local overlapParams = OverlapParams.new()
|
|
77
|
-
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
78
|
-
overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent }
|
|
79
|
-
|
|
80
|
-
local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams)
|
|
81
|
-
|
|
82
|
-
local hitCharacters: {[Model]: true} = {}
|
|
83
|
-
local results: {Model} = {}
|
|
84
|
-
|
|
85
|
-
for _, part in parts do
|
|
86
|
-
local model = part:FindFirstAncestorWhichIsA("Model")
|
|
87
|
-
if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then
|
|
88
|
-
hitCharacters[model] = true
|
|
89
|
-
table.insert(results, model)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
return results
|
|
94
|
-
end
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### Alternative: `GetPartsInPart`
|
|
98
|
-
|
|
99
|
-
When you have a physical hitbox part (e.g., a sword blade):
|
|
100
|
-
|
|
101
|
-
```luau
|
|
102
|
-
local overlapParams = OverlapParams.new()
|
|
103
|
-
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
104
|
-
overlapParams.FilterDescendantsInstances = { swordModel }
|
|
105
|
-
|
|
106
|
-
local touchingParts = workspace:GetPartsInPart(hitboxPart, overlapParams)
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
### Raycast Detection (Ranged / Hitscan)
|
|
110
|
-
|
|
111
|
-
```luau
|
|
112
|
-
local function hitscanRaycast(origin: Vector3, direction: Vector3, ignoreList: {Instance}): RaycastResult?
|
|
113
|
-
local raycastParams = RaycastParams.new()
|
|
114
|
-
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
115
|
-
raycastParams.FilterDescendantsInstances = ignoreList
|
|
116
|
-
|
|
117
|
-
return workspace:Raycast(origin, direction, raycastParams)
|
|
118
|
-
end
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
### Hitbox Sizing Guidelines
|
|
122
|
-
|
|
123
|
-
| Weapon Type | Range (studs) | Width (studs) | Height (studs) |
|
|
124
|
-
|---|---|---|---|
|
|
125
|
-
| Dagger / Fist | 4-5 | 4 | 5 |
|
|
126
|
-
| Sword | 6-8 | 5 | 6 |
|
|
127
|
-
| Greatsword | 8-12 | 7 | 7 |
|
|
128
|
-
| Spear / Polearm | 10-14 | 3 | 5 |
|
|
129
|
-
| AoE Slam | 8-10 | 10 | 8 |
|
|
130
|
-
|
|
131
|
-
Position the hitbox CFrame in front of the character's HumanoidRootPart. Offset by half the range on the Z axis so the box starts at the character and extends forward.
|
|
132
|
-
|
|
133
|
-
---
|
|
134
|
-
|
|
135
|
-
## 4. State Machines
|
|
136
|
-
|
|
137
|
-
Combat states prevent invalid action combinations (e.g., attacking while stunned) and enforce recovery windows.
|
|
138
|
-
|
|
139
|
-
### State Diagram
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
+-----------+
|
|
143
|
-
+------>| Idle |<------+
|
|
144
|
-
| +-----+-----+ |
|
|
145
|
-
| | |
|
|
146
|
-
(recovery (attack (block
|
|
147
|
-
expires) input) input)
|
|
148
|
-
| | |
|
|
149
|
-
| +-----v-----+ |
|
|
150
|
-
| | Attacking | |
|
|
151
|
-
| +-----+-----+ |
|
|
152
|
-
| | |
|
|
153
|
-
| (attack +---+----+
|
|
154
|
-
| ends) | Blocking|
|
|
155
|
-
| | +---+----+
|
|
156
|
-
| +-----v-----+ |
|
|
157
|
-
+-------+ Recovery | |
|
|
158
|
-
| +-----------+ (release
|
|
159
|
-
| block)
|
|
160
|
-
| |
|
|
161
|
-
+----+-----+ +-----v-----+
|
|
162
|
-
| Stunned +----------->| Idle |
|
|
163
|
-
+----------+ (stun +-----------+
|
|
164
|
-
expires)
|
|
165
|
-
|
|
166
|
-
Dodge can interrupt Idle or Blocking:
|
|
167
|
-
Idle/Blocking --> Dodging --> Idle
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### Complete State Machine Implementation
|
|
171
|
-
|
|
172
|
-
```luau
|
|
173
|
-
--!strict
|
|
174
|
-
-- CombatStateMachine.luau (ModuleScript in ReplicatedStorage)
|
|
175
|
-
|
|
176
|
-
export type CombatState = "Idle" | "Attacking" | "Recovery" | "Blocking" | "Dodging" | "Stunned"
|
|
177
|
-
|
|
178
|
-
export type StateTransition = {
|
|
179
|
-
from: {CombatState},
|
|
180
|
-
to: CombatState,
|
|
181
|
-
condition: ((context: StateMachineContext) -> boolean)?,
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export type StateMachineContext = {
|
|
185
|
-
player: Player,
|
|
186
|
-
currentState: CombatState,
|
|
187
|
-
stateStartTime: number,
|
|
188
|
-
lastAttackTime: number,
|
|
189
|
-
comboCount: number,
|
|
190
|
-
stunEndTime: number,
|
|
191
|
-
dodgeCooldownEnd: number,
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
local CombatStateMachine = {}
|
|
195
|
-
CombatStateMachine.__index = CombatStateMachine
|
|
196
|
-
|
|
197
|
-
local RECOVERY_DURATION = 0.4
|
|
198
|
-
local DODGE_DURATION = 0.5
|
|
199
|
-
local DODGE_COOLDOWN = 1.5
|
|
200
|
-
local STUN_DEFAULT_DURATION = 1.0
|
|
201
|
-
local ATTACK_DURATION = 0.35
|
|
202
|
-
local COMBO_WINDOW = 0.8
|
|
203
|
-
local MAX_COMBO = 4
|
|
204
|
-
|
|
205
|
-
local VALID_TRANSITIONS: {StateTransition} = {
|
|
206
|
-
{ from = { "Idle" }, to = "Attacking" },
|
|
207
|
-
{ from = { "Attacking" }, to = "Recovery" },
|
|
208
|
-
{ from = { "Recovery" }, to = "Idle" },
|
|
209
|
-
{ from = { "Recovery" }, to = "Attacking" }, -- combo: attack during recovery window
|
|
210
|
-
{ from = { "Idle" }, to = "Blocking" },
|
|
211
|
-
{ from = { "Blocking" }, to = "Idle" },
|
|
212
|
-
{ from = { "Idle", "Blocking" }, to = "Dodging" },
|
|
213
|
-
{ from = { "Dodging" }, to = "Idle" },
|
|
214
|
-
{ from = { "Idle", "Attacking", "Recovery", "Blocking", "Dodging" }, to = "Stunned" },
|
|
215
|
-
{ from = { "Stunned" }, to = "Idle" },
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function CombatStateMachine.new(player: Player)
|
|
219
|
-
local self = setmetatable({}, CombatStateMachine)
|
|
220
|
-
self.context = {
|
|
221
|
-
player = player,
|
|
222
|
-
currentState = "Idle" :: CombatState,
|
|
223
|
-
stateStartTime = os.clock(),
|
|
224
|
-
lastAttackTime = 0,
|
|
225
|
-
comboCount = 0,
|
|
226
|
-
stunEndTime = 0,
|
|
227
|
-
dodgeCooldownEnd = 0,
|
|
228
|
-
} :: StateMachineContext
|
|
229
|
-
self._onStateChanged = {} :: {(CombatState, CombatState) -> ()}
|
|
230
|
-
return self
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
function CombatStateMachine:getState(): CombatState
|
|
234
|
-
return self.context.currentState
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
function CombatStateMachine:getComboCount(): number
|
|
238
|
-
return self.context.comboCount
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
function CombatStateMachine:onStateChanged(callback: (CombatState, CombatState) -> ())
|
|
242
|
-
table.insert(self._onStateChanged, callback)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
function CombatStateMachine:canTransition(to: CombatState): boolean
|
|
246
|
-
local from = self.context.currentState
|
|
247
|
-
|
|
248
|
-
for _, transition in VALID_TRANSITIONS do
|
|
249
|
-
if transition.to == to and table.find(transition.from, from) then
|
|
250
|
-
if transition.condition and not transition.condition(self.context) then
|
|
251
|
-
continue
|
|
252
|
-
end
|
|
253
|
-
return true
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
return false
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
function CombatStateMachine:transition(to: CombatState): boolean
|
|
261
|
-
if not self:canTransition(to) then
|
|
262
|
-
return false
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
local from = self.context.currentState
|
|
266
|
-
self.context.currentState = to
|
|
267
|
-
self.context.stateStartTime = os.clock()
|
|
268
|
-
|
|
269
|
-
for _, callback in self._onStateChanged do
|
|
270
|
-
callback(from, to)
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
return true
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
function CombatStateMachine:tryAttack(): boolean
|
|
277
|
-
local now = os.clock()
|
|
278
|
-
local ctx = self.context
|
|
279
|
-
|
|
280
|
-
-- Combo: if in Recovery and within combo window, allow chaining
|
|
281
|
-
if ctx.currentState == "Recovery" then
|
|
282
|
-
local timeSinceAttack = now - ctx.lastAttackTime
|
|
283
|
-
if timeSinceAttack <= COMBO_WINDOW and ctx.comboCount < MAX_COMBO then
|
|
284
|
-
ctx.comboCount += 1
|
|
285
|
-
ctx.lastAttackTime = now
|
|
286
|
-
return self:transition("Attacking")
|
|
287
|
-
end
|
|
288
|
-
return false
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
if ctx.currentState ~= "Idle" then
|
|
292
|
-
return false
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
ctx.comboCount = 1
|
|
296
|
-
ctx.lastAttackTime = now
|
|
297
|
-
return self:transition("Attacking")
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
function CombatStateMachine:tryBlock(): boolean
|
|
301
|
-
return self:transition("Blocking")
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
function CombatStateMachine:releaseBlock(): boolean
|
|
305
|
-
if self.context.currentState ~= "Blocking" then
|
|
306
|
-
return false
|
|
307
|
-
end
|
|
308
|
-
return self:transition("Idle")
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
function CombatStateMachine:tryDodge(): boolean
|
|
312
|
-
local now = os.clock()
|
|
313
|
-
if now < self.context.dodgeCooldownEnd then
|
|
314
|
-
return false
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
if self:transition("Dodging") then
|
|
318
|
-
self.context.dodgeCooldownEnd = now + DODGE_COOLDOWN
|
|
319
|
-
return true
|
|
320
|
-
end
|
|
321
|
-
return false
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
function CombatStateMachine:applyStun(duration: number?)
|
|
325
|
-
local stunDuration = duration or STUN_DEFAULT_DURATION
|
|
326
|
-
self.context.stunEndTime = os.clock() + stunDuration
|
|
327
|
-
self:transition("Stunned")
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
function CombatStateMachine:update()
|
|
331
|
-
local now = os.clock()
|
|
332
|
-
local ctx = self.context
|
|
333
|
-
local elapsed = now - ctx.stateStartTime
|
|
334
|
-
|
|
335
|
-
if ctx.currentState == "Attacking" and elapsed >= ATTACK_DURATION then
|
|
336
|
-
self:transition("Recovery")
|
|
337
|
-
elseif ctx.currentState == "Recovery" and elapsed >= RECOVERY_DURATION then
|
|
338
|
-
ctx.comboCount = 0
|
|
339
|
-
self:transition("Idle")
|
|
340
|
-
elseif ctx.currentState == "Dodging" and elapsed >= DODGE_DURATION then
|
|
341
|
-
self:transition("Idle")
|
|
342
|
-
elseif ctx.currentState == "Stunned" and now >= ctx.stunEndTime then
|
|
343
|
-
self:transition("Idle")
|
|
344
|
-
end
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
function CombatStateMachine:destroy()
|
|
348
|
-
table.clear(self._onStateChanged)
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
return CombatStateMachine
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
### Tuning Constants
|
|
355
|
-
|
|
356
|
-
| Constant | Default | Effect |
|
|
357
|
-
|---|---|---|
|
|
358
|
-
| `ATTACK_DURATION` | 0.35s | How long the attack hitbox is active |
|
|
359
|
-
| `RECOVERY_DURATION` | 0.4s | Window after attack before returning to Idle |
|
|
360
|
-
| `COMBO_WINDOW` | 0.8s | Time after an attack during which the next attack counts as a combo |
|
|
361
|
-
| `MAX_COMBO` | 4 | Maximum consecutive hits in a combo chain |
|
|
362
|
-
| `DODGE_DURATION` | 0.5s | Invulnerability / movement duration |
|
|
363
|
-
| `DODGE_COOLDOWN` | 1.5s | Time between dodge uses |
|
|
364
|
-
| `STUN_DEFAULT_DURATION` | 1.0s | Default stun length |
|
|
365
|
-
|
|
366
|
-
---
|
|
367
|
-
|
|
368
|
-
## 5. Damage Calculation
|
|
369
|
-
|
|
370
|
-
### Formula
|
|
371
|
-
|
|
372
|
-
```
|
|
373
|
-
finalDamage = baseDamage
|
|
374
|
-
* weaponMultiplier
|
|
375
|
-
* (1 + totalBuffPercent)
|
|
376
|
-
* (1 - defensePercent)
|
|
377
|
-
* critMultiplier
|
|
378
|
-
* comboMultiplier
|
|
379
|
-
* typeEffectiveness
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
### Implementation
|
|
383
|
-
|
|
384
|
-
```luau
|
|
385
|
-
--!strict
|
|
386
|
-
-- DamageCalculator.luau (ModuleScript in ServerScriptService)
|
|
387
|
-
|
|
388
|
-
export type DamageType = "Physical" | "Magical" | "Fire" | "Ice" | "Lightning"
|
|
389
|
-
|
|
390
|
-
export type WeaponStats = {
|
|
391
|
-
baseDamage: number,
|
|
392
|
-
weaponMultiplier: number,
|
|
393
|
-
damageType: DamageType,
|
|
394
|
-
critChance: number, -- 0.0 to 1.0
|
|
395
|
-
critMultiplier: number, -- e.g. 1.5 = 150% damage on crit
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
export type CombatStats = {
|
|
399
|
-
attackBuff: number, -- 0.0 to 1.0 (e.g. 0.25 = 25% buff)
|
|
400
|
-
defense: number, -- 0.0 to 1.0 (e.g. 0.3 = 30% damage reduction)
|
|
401
|
-
resistances: {[DamageType]: number}, -- 0.0 to 1.0 per type
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
export type DamageResult = {
|
|
405
|
-
rawDamage: number,
|
|
406
|
-
finalDamage: number,
|
|
407
|
-
isCritical: boolean,
|
|
408
|
-
damageType: DamageType,
|
|
409
|
-
blocked: boolean,
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
local DamageCalculator = {}
|
|
413
|
-
|
|
414
|
-
local COMBO_MULTIPLIERS = { 1.0, 1.1, 1.25, 1.5 }
|
|
415
|
-
local BLOCK_REDUCTION = 0.8 -- blocking reduces damage by 80%
|
|
416
|
-
local MIN_DAMAGE = 1
|
|
417
|
-
|
|
418
|
-
function DamageCalculator.calculate(
|
|
419
|
-
weapon: WeaponStats,
|
|
420
|
-
attackerStats: CombatStats,
|
|
421
|
-
defenderStats: CombatStats,
|
|
422
|
-
comboHit: number,
|
|
423
|
-
isBlocking: boolean
|
|
424
|
-
): DamageResult
|
|
425
|
-
local baseDamage = weapon.baseDamage * weapon.weaponMultiplier
|
|
426
|
-
|
|
427
|
-
-- Buff multiplier
|
|
428
|
-
local buffMultiplier = 1 + attackerStats.attackBuff
|
|
429
|
-
|
|
430
|
-
-- Defense multiplier
|
|
431
|
-
local defenseMultiplier = 1 - math.clamp(defenderStats.defense, 0, 0.9) -- cap at 90%
|
|
432
|
-
|
|
433
|
-
-- Elemental resistance
|
|
434
|
-
local resistance = defenderStats.resistances[weapon.damageType] or 0
|
|
435
|
-
local typeMultiplier = 1 - math.clamp(resistance, 0, 0.9)
|
|
436
|
-
|
|
437
|
-
-- Critical hit
|
|
438
|
-
local isCritical = math.random() < weapon.critChance
|
|
439
|
-
local critMultiplier = if isCritical then weapon.critMultiplier else 1.0
|
|
440
|
-
|
|
441
|
-
-- Combo scaling
|
|
442
|
-
local comboIndex = math.clamp(comboHit, 1, #COMBO_MULTIPLIERS)
|
|
443
|
-
local comboMultiplier = COMBO_MULTIPLIERS[comboIndex]
|
|
444
|
-
|
|
445
|
-
-- Combine
|
|
446
|
-
local rawDamage = baseDamage * buffMultiplier * critMultiplier * comboMultiplier
|
|
447
|
-
local finalDamage = rawDamage * defenseMultiplier * typeMultiplier
|
|
448
|
-
|
|
449
|
-
-- Blocking
|
|
450
|
-
local blocked = false
|
|
451
|
-
if isBlocking then
|
|
452
|
-
finalDamage *= (1 - BLOCK_REDUCTION)
|
|
453
|
-
blocked = true
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
finalDamage = math.max(math.floor(finalDamage), MIN_DAMAGE)
|
|
457
|
-
|
|
458
|
-
return {
|
|
459
|
-
rawDamage = rawDamage,
|
|
460
|
-
finalDamage = finalDamage,
|
|
461
|
-
isCritical = isCritical,
|
|
462
|
-
damageType = weapon.damageType,
|
|
463
|
-
blocked = blocked,
|
|
464
|
-
}
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
return DamageCalculator
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
### Damage Numbers Display (Client)
|
|
471
|
-
|
|
472
|
-
```luau
|
|
473
|
-
-- DamageDisplay.luau (ModuleScript in ReplicatedStorage, called from client)
|
|
474
|
-
|
|
475
|
-
local TweenService = game:GetService("TweenService")
|
|
476
|
-
|
|
477
|
-
local DamageDisplay = {}
|
|
478
|
-
|
|
479
|
-
local COLORS = {
|
|
480
|
-
Normal = Color3.fromRGB(255, 255, 255),
|
|
481
|
-
Critical = Color3.fromRGB(255, 50, 50),
|
|
482
|
-
Blocked = Color3.fromRGB(150, 150, 150),
|
|
483
|
-
Heal = Color3.fromRGB(50, 255, 50),
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function DamageDisplay.show(target: Model, amount: number, isCritical: boolean, blocked: boolean)
|
|
487
|
-
local head = target:FindFirstChild("Head") :: BasePart?
|
|
488
|
-
if not head then
|
|
489
|
-
return
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
local billboard = Instance.new("BillboardGui")
|
|
493
|
-
billboard.Size = UDim2.fromOffset(100, 40)
|
|
494
|
-
billboard.StudsOffset = Vector3.new(math.random(-2, 2), 2, 0)
|
|
495
|
-
billboard.AlwaysOnTop = true
|
|
496
|
-
billboard.Parent = head
|
|
497
|
-
|
|
498
|
-
local label = Instance.new("TextLabel")
|
|
499
|
-
label.Size = UDim2.fromScale(1, 1)
|
|
500
|
-
label.BackgroundTransparency = 1
|
|
501
|
-
label.Text = tostring(amount)
|
|
502
|
-
label.Font = Enum.Font.GothamBold
|
|
503
|
-
label.TextScaled = true
|
|
504
|
-
|
|
505
|
-
if blocked then
|
|
506
|
-
label.TextColor3 = COLORS.Blocked
|
|
507
|
-
label.Text = amount .. " (Blocked)"
|
|
508
|
-
elseif isCritical then
|
|
509
|
-
label.TextColor3 = COLORS.Critical
|
|
510
|
-
label.Text = amount .. "!"
|
|
511
|
-
label.TextSize = 28
|
|
512
|
-
else
|
|
513
|
-
label.TextColor3 = COLORS.Normal
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
label.Parent = billboard
|
|
517
|
-
|
|
518
|
-
-- Float up and fade
|
|
519
|
-
local tweenInfo = TweenInfo.new(1.0, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
520
|
-
local tween = TweenService:Create(billboard, tweenInfo, {
|
|
521
|
-
StudsOffset = billboard.StudsOffset + Vector3.new(0, 3, 0),
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
local fadeTween = TweenService:Create(label, tweenInfo, {
|
|
525
|
-
TextTransparency = 1,
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
tween:Play()
|
|
529
|
-
fadeTween:Play()
|
|
530
|
-
|
|
531
|
-
fadeTween.Completed:Once(function()
|
|
532
|
-
billboard:Destroy()
|
|
533
|
-
end)
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
return DamageDisplay
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
---
|
|
540
|
-
|
|
541
|
-
## 6. Melee Combat
|
|
542
|
-
|
|
543
|
-
### Swing Detection
|
|
544
|
-
|
|
545
|
-
The server creates a hitbox in front of the attacker for the duration of the attack state. Only characters that intersect the box during the active window take damage.
|
|
546
|
-
|
|
547
|
-
```luau
|
|
548
|
-
local function performMeleeAttack(attacker: Model, weapon: WeaponStats, comboHit: number): {DamageResult}
|
|
549
|
-
local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
550
|
-
if not rootPart then
|
|
551
|
-
return {}
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
local targets = getHitTargets(rootPart, weapon.range or 7, weapon.width or 5, weapon.height or 6)
|
|
555
|
-
local results: {DamageResult} = {}
|
|
556
|
-
|
|
557
|
-
for _, targetModel in targets do
|
|
558
|
-
local humanoid = targetModel:FindFirstChildWhichIsA("Humanoid")
|
|
559
|
-
if not humanoid or humanoid.Health <= 0 then
|
|
560
|
-
continue
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
local defenderStats = getStatsForCharacter(targetModel) -- your stats lookup
|
|
564
|
-
local isBlocking = getStateMachine(targetModel):getState() == "Blocking"
|
|
565
|
-
|
|
566
|
-
local result = DamageCalculator.calculate(weapon, getStatsForCharacter(attacker), defenderStats, comboHit, isBlocking)
|
|
567
|
-
humanoid:TakeDamage(result.finalDamage)
|
|
568
|
-
table.insert(results, result)
|
|
569
|
-
end
|
|
570
|
-
|
|
571
|
-
return results
|
|
572
|
-
end
|
|
573
|
-
```
|
|
574
|
-
|
|
575
|
-
### Combo System
|
|
576
|
-
|
|
577
|
-
Track consecutive hits within the combo window. Each successive hit escalates damage:
|
|
578
|
-
|
|
579
|
-
| Combo Hit | Multiplier | Typical Effect |
|
|
580
|
-
|---|---|---|
|
|
581
|
-
| 1 | 1.0x | Normal swing |
|
|
582
|
-
| 2 | 1.1x | Faster animation |
|
|
583
|
-
| 3 | 1.25x | Wider hitbox |
|
|
584
|
-
| 4 | 1.5x | Finisher with knockback |
|
|
585
|
-
|
|
586
|
-
The state machine tracks `comboCount`. When the combo window expires (player doesn't attack within `COMBO_WINDOW` seconds), the count resets to 0 on transition back to Idle.
|
|
587
|
-
|
|
588
|
-
### Parry / Block
|
|
589
|
-
|
|
590
|
-
- **Block**: Hold a button to enter Blocking state. Incoming damage is reduced by `BLOCK_REDUCTION` (80%). Blocking drains a stamina resource (optional). If stamina hits 0, the block breaks and the player is Stunned.
|
|
591
|
-
- **Parry**: A precisely timed block (within ~0.15s of an incoming attack) negates all damage and stuns the attacker instead. Implementation: check if the defender entered Blocking state within a `PARRY_WINDOW` before the hit lands.
|
|
592
|
-
|
|
593
|
-
```luau
|
|
594
|
-
local PARRY_WINDOW = 0.15
|
|
595
|
-
|
|
596
|
-
local function isParry(defenderStateMachine): boolean
|
|
597
|
-
if defenderStateMachine:getState() ~= "Blocking" then
|
|
598
|
-
return false
|
|
599
|
-
end
|
|
600
|
-
local blockDuration = os.clock() - defenderStateMachine.context.stateStartTime
|
|
601
|
-
return blockDuration <= PARRY_WINDOW
|
|
602
|
-
end
|
|
603
|
-
```
|
|
604
|
-
|
|
605
|
-
### Knockback
|
|
606
|
-
|
|
607
|
-
Apply knockback on combo finishers or heavy attacks using `LinearVelocity` (preferred over deprecated `BodyVelocity`):
|
|
608
|
-
|
|
609
|
-
```luau
|
|
610
|
-
local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number, duration: number)
|
|
611
|
-
local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
|
|
612
|
-
if not attachment then
|
|
613
|
-
return
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
local linearVelocity = Instance.new("LinearVelocity")
|
|
617
|
-
linearVelocity.Attachment0 = attachment
|
|
618
|
-
linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
|
|
619
|
-
linearVelocity.MaxForce = math.huge
|
|
620
|
-
linearVelocity.VectorVelocity = direction.Unit * force
|
|
621
|
-
linearVelocity.Parent = targetRootPart
|
|
622
|
-
|
|
623
|
-
task.delay(duration, function()
|
|
624
|
-
linearVelocity:Destroy()
|
|
625
|
-
end)
|
|
626
|
-
end
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
---
|
|
630
|
-
|
|
631
|
-
## 7. Ranged Combat
|
|
632
|
-
|
|
633
|
-
### Projectile System
|
|
634
|
-
|
|
635
|
-
Create a physical part that moves each frame. Good for visible, dodgeable projectiles.
|
|
636
|
-
|
|
637
|
-
```luau
|
|
638
|
-
local RunService = game:GetService("RunService")
|
|
639
|
-
|
|
640
|
-
local function fireProjectile(origin: CFrame, speed: number, maxDistance: number, gravity: number, onHit: (RaycastResult) -> ())
|
|
641
|
-
local projectile = Instance.new("Part")
|
|
642
|
-
projectile.Size = Vector3.new(0.3, 0.3, 1)
|
|
643
|
-
projectile.CFrame = origin
|
|
644
|
-
projectile.Anchored = true
|
|
645
|
-
projectile.CanCollide = false
|
|
646
|
-
projectile.Parent = workspace
|
|
647
|
-
|
|
648
|
-
local velocity = origin.LookVector * speed
|
|
649
|
-
local distanceTraveled = 0
|
|
650
|
-
|
|
651
|
-
local raycastParams = RaycastParams.new()
|
|
652
|
-
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
653
|
-
raycastParams.FilterDescendantsInstances = { projectile }
|
|
654
|
-
|
|
655
|
-
local connection: RBXScriptConnection
|
|
656
|
-
connection = RunService.Heartbeat:Connect(function(dt: number)
|
|
657
|
-
-- Apply gravity
|
|
658
|
-
velocity += Vector3.new(0, -gravity * dt, 0)
|
|
659
|
-
|
|
660
|
-
local displacement = velocity * dt
|
|
661
|
-
local rayResult = workspace:Raycast(projectile.Position, displacement, raycastParams)
|
|
662
|
-
|
|
663
|
-
if rayResult then
|
|
664
|
-
connection:Disconnect()
|
|
665
|
-
onHit(rayResult)
|
|
666
|
-
projectile:Destroy()
|
|
667
|
-
return
|
|
668
|
-
end
|
|
669
|
-
|
|
670
|
-
projectile.CFrame = CFrame.new(projectile.Position + displacement, projectile.Position + displacement + velocity)
|
|
671
|
-
distanceTraveled += displacement.Magnitude
|
|
672
|
-
|
|
673
|
-
if distanceTraveled >= maxDistance then
|
|
674
|
-
connection:Disconnect()
|
|
675
|
-
projectile:Destroy()
|
|
676
|
-
end
|
|
677
|
-
end)
|
|
678
|
-
end
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
### Hitscan (Instant Raycast)
|
|
682
|
-
|
|
683
|
-
For sniper rifles, laser beams, or any instant-hit weapon:
|
|
684
|
-
|
|
685
|
-
```luau
|
|
686
|
-
local function hitscanAttack(attacker: Model, aimDirection: Vector3, maxRange: number): RaycastResult?
|
|
687
|
-
local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
688
|
-
if not rootPart then
|
|
689
|
-
return nil
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
local origin = rootPart.Position + Vector3.new(0, 1.5, 0) -- eye height
|
|
693
|
-
local raycastParams = RaycastParams.new()
|
|
694
|
-
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
695
|
-
raycastParams.FilterDescendantsInstances = { attacker }
|
|
696
|
-
|
|
697
|
-
return workspace:Raycast(origin, aimDirection.Unit * maxRange, raycastParams)
|
|
698
|
-
end
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
### Bullet Drop
|
|
702
|
-
|
|
703
|
-
Simulate gravity over distance by bending the ray. For longer ranges, split into segments:
|
|
704
|
-
|
|
705
|
-
```luau
|
|
706
|
-
local function raycastWithDrop(origin: Vector3, direction: Vector3, segments: number, dropPerSegment: number): RaycastResult?
|
|
707
|
-
local segmentLength = direction.Magnitude / segments
|
|
708
|
-
local currentPos = origin
|
|
709
|
-
local currentDir = direction.Unit
|
|
710
|
-
|
|
711
|
-
local raycastParams = RaycastParams.new()
|
|
712
|
-
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
713
|
-
|
|
714
|
-
for i = 1, segments do
|
|
715
|
-
local drop = Vector3.new(0, -dropPerSegment * i, 0)
|
|
716
|
-
local segmentDir = (currentDir * segmentLength) + drop
|
|
717
|
-
|
|
718
|
-
local result = workspace:Raycast(currentPos, segmentDir, raycastParams)
|
|
719
|
-
if result then
|
|
720
|
-
return result
|
|
721
|
-
end
|
|
722
|
-
|
|
723
|
-
currentPos += segmentDir
|
|
724
|
-
end
|
|
725
|
-
|
|
726
|
-
return nil
|
|
727
|
-
end
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
### Spread / Bloom Patterns
|
|
731
|
-
|
|
732
|
-
Add random deviation within a cone for shotguns, automatic weapons, or hip-fire:
|
|
733
|
-
|
|
734
|
-
```luau
|
|
735
|
-
local function applySpread(direction: Vector3, spreadAngleDegrees: number): Vector3
|
|
736
|
-
local spreadRadians = math.rad(spreadAngleDegrees)
|
|
737
|
-
local randomAngle = math.random() * math.pi * 2
|
|
738
|
-
local randomSpread = math.random() * spreadRadians
|
|
739
|
-
|
|
740
|
-
local right = direction:Cross(Vector3.yAxis).Unit
|
|
741
|
-
local up = right:Cross(direction).Unit
|
|
742
|
-
|
|
743
|
-
local offset = (right * math.cos(randomAngle) + up * math.sin(randomAngle)) * math.sin(randomSpread)
|
|
744
|
-
|
|
745
|
-
return (direction.Unit + offset).Unit
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
-- Usage: shotgun with 8 pellets, 12-degree spread
|
|
749
|
-
for i = 1, 8 do
|
|
750
|
-
local spreadDir = applySpread(aimDirection, 12)
|
|
751
|
-
local result = hitscanAttack(attacker, spreadDir, 50)
|
|
752
|
-
if result then
|
|
753
|
-
-- process hit
|
|
754
|
-
end
|
|
755
|
-
end
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
---
|
|
759
|
-
|
|
760
|
-
## 8. WCS (Weapon Combat System) Framework
|
|
761
|
-
|
|
762
|
-
### What It Is
|
|
763
|
-
|
|
764
|
-
WCS is a community-built framework for Roblox combat that provides:
|
|
765
|
-
|
|
766
|
-
- Skill/ability definition with built-in cooldowns
|
|
767
|
-
- Status effects (buffs, debuffs, DoTs)
|
|
768
|
-
- Moveset management (assign skills to characters)
|
|
769
|
-
- Client-server synchronization out of the box
|
|
770
|
-
- Holdable skills, channeling, and charge mechanics
|
|
771
|
-
|
|
772
|
-
### When to Use WCS
|
|
773
|
-
|
|
774
|
-
| Scenario | Recommendation |
|
|
775
|
-
|---|---|
|
|
776
|
-
| Complex skill-based combat (RPG, fighting game) | **Use WCS** -- saves weeks of boilerplate |
|
|
777
|
-
| Many unique abilities with varied behavior | **Use WCS** -- skill definition system is well designed |
|
|
778
|
-
| Simple melee/ranged with few weapons | **Build custom** -- WCS adds unnecessary overhead |
|
|
779
|
-
| Learning combat fundamentals | **Build custom** -- understand the internals first |
|
|
780
|
-
| Very custom combo/input systems | **Build custom or extend WCS** -- may fight the framework |
|
|
781
|
-
|
|
782
|
-
### Basic WCS Skill Definition
|
|
783
|
-
|
|
784
|
-
```luau
|
|
785
|
-
-- Example: a fireball skill in WCS
|
|
786
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
787
|
-
local WCS = require(ReplicatedStorage.Packages.WCS)
|
|
788
|
-
|
|
789
|
-
local Fireball = WCS.RegisterSkill("Fireball")
|
|
790
|
-
Fireball.CooldownTime = 3
|
|
791
|
-
Fireball.MaxHoldTime = 2
|
|
792
|
-
|
|
793
|
-
function Fireball:OnStartServer()
|
|
794
|
-
-- Server-side fireball logic
|
|
795
|
-
local character = self.Character
|
|
796
|
-
-- spawn projectile, deal damage, etc.
|
|
797
|
-
end
|
|
798
|
-
|
|
799
|
-
function Fireball:OnStartClient()
|
|
800
|
-
-- Client-side VFX
|
|
801
|
-
-- play casting animation, spawn particle emitter
|
|
802
|
-
end
|
|
803
|
-
```
|
|
804
|
-
|
|
805
|
-
Install WCS via Wally: `wcs = "digest/wcs@latest"` or grab the model from the toolbox.
|
|
806
|
-
|
|
807
|
-
---
|
|
808
|
-
|
|
809
|
-
## 9. Cooldown Systems
|
|
810
|
-
|
|
811
|
-
### Server-Side Cooldown Tracking
|
|
812
|
-
|
|
813
|
-
All cooldowns must be tracked on the server. The client can display a timer, but the server rejects actions that violate cooldowns.
|
|
814
|
-
|
|
815
|
-
```luau
|
|
816
|
-
--!strict
|
|
817
|
-
-- CooldownManager.luau (ModuleScript in ServerScriptService)
|
|
818
|
-
|
|
819
|
-
local CooldownManager = {}
|
|
820
|
-
CooldownManager.__index = CooldownManager
|
|
821
|
-
|
|
822
|
-
type CooldownMap = {[string]: number} -- abilityName -> expiry timestamp
|
|
823
|
-
type PlayerCooldowns = {[Player]: CooldownMap}
|
|
824
|
-
|
|
825
|
-
local cooldowns: PlayerCooldowns = {}
|
|
826
|
-
|
|
827
|
-
function CooldownManager.initialize(player: Player)
|
|
828
|
-
cooldowns[player] = {}
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
function CooldownManager.cleanup(player: Player)
|
|
832
|
-
cooldowns[player] = nil
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
function CooldownManager.isReady(player: Player, abilityName: string): boolean
|
|
836
|
-
local playerCooldowns = cooldowns[player]
|
|
837
|
-
if not playerCooldowns then
|
|
838
|
-
return false
|
|
839
|
-
end
|
|
840
|
-
|
|
841
|
-
local expiry = playerCooldowns[abilityName]
|
|
842
|
-
if not expiry then
|
|
843
|
-
return true
|
|
844
|
-
end
|
|
845
|
-
|
|
846
|
-
return os.clock() >= expiry
|
|
847
|
-
end
|
|
848
|
-
|
|
849
|
-
function CooldownManager.startCooldown(player: Player, abilityName: string, duration: number)
|
|
850
|
-
local playerCooldowns = cooldowns[player]
|
|
851
|
-
if not playerCooldowns then
|
|
852
|
-
return
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
playerCooldowns[abilityName] = os.clock() + duration
|
|
856
|
-
end
|
|
857
|
-
|
|
858
|
-
function CooldownManager.getRemainingTime(player: Player, abilityName: string): number
|
|
859
|
-
local playerCooldowns = cooldowns[player]
|
|
860
|
-
if not playerCooldowns then
|
|
861
|
-
return 0
|
|
862
|
-
end
|
|
863
|
-
|
|
864
|
-
local expiry = playerCooldowns[abilityName]
|
|
865
|
-
if not expiry then
|
|
866
|
-
return 0
|
|
867
|
-
end
|
|
868
|
-
|
|
869
|
-
return math.max(0, expiry - os.clock())
|
|
870
|
-
end
|
|
871
|
-
|
|
872
|
-
return CooldownManager
|
|
873
|
-
```
|
|
874
|
-
|
|
875
|
-
### Client-Side Cooldown Display
|
|
876
|
-
|
|
877
|
-
```luau
|
|
878
|
-
-- CooldownUI.luau (LocalScript in StarterPlayerScripts)
|
|
879
|
-
|
|
880
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
881
|
-
local RunService = game:GetService("RunService")
|
|
882
|
-
local cooldownEvent = ReplicatedStorage:WaitForChild("CooldownStarted") :: RemoteEvent
|
|
883
|
-
|
|
884
|
-
local activeCooldowns: {[string]: {startTime: number, duration: number, frame: Frame}} = {}
|
|
885
|
-
|
|
886
|
-
cooldownEvent.OnClientEvent:Connect(function(abilityName: string, duration: number)
|
|
887
|
-
local frame = findAbilityFrame(abilityName) -- your UI lookup
|
|
888
|
-
if not frame then
|
|
889
|
-
return
|
|
890
|
-
end
|
|
891
|
-
|
|
892
|
-
local overlay = frame:FindFirstChild("CooldownOverlay") :: Frame
|
|
893
|
-
local label = frame:FindFirstChild("CooldownLabel") :: TextLabel
|
|
894
|
-
|
|
895
|
-
activeCooldowns[abilityName] = {
|
|
896
|
-
startTime = os.clock(),
|
|
897
|
-
duration = duration,
|
|
898
|
-
frame = frame,
|
|
899
|
-
}
|
|
900
|
-
end)
|
|
901
|
-
|
|
902
|
-
RunService.RenderStepped:Connect(function()
|
|
903
|
-
for abilityName, data in activeCooldowns do
|
|
904
|
-
local elapsed = os.clock() - data.startTime
|
|
905
|
-
local remaining = data.duration - elapsed
|
|
906
|
-
|
|
907
|
-
if remaining <= 0 then
|
|
908
|
-
-- Cooldown finished
|
|
909
|
-
local overlay = data.frame:FindFirstChild("CooldownOverlay") :: Frame?
|
|
910
|
-
if overlay then
|
|
911
|
-
overlay.Size = UDim2.fromScale(1, 0)
|
|
912
|
-
end
|
|
913
|
-
local label = data.frame:FindFirstChild("CooldownLabel") :: TextLabel?
|
|
914
|
-
if label then
|
|
915
|
-
label.Text = ""
|
|
916
|
-
end
|
|
917
|
-
activeCooldowns[abilityName] = nil
|
|
918
|
-
continue
|
|
919
|
-
end
|
|
920
|
-
|
|
921
|
-
-- Update sweep and text
|
|
922
|
-
local fraction = remaining / data.duration
|
|
923
|
-
local overlay = data.frame:FindFirstChild("CooldownOverlay") :: Frame?
|
|
924
|
-
if overlay then
|
|
925
|
-
overlay.Size = UDim2.fromScale(1, fraction)
|
|
926
|
-
end
|
|
927
|
-
local label = data.frame:FindFirstChild("CooldownLabel") :: TextLabel?
|
|
928
|
-
if label then
|
|
929
|
-
label.Text = string.format("%.1f", remaining)
|
|
930
|
-
end
|
|
931
|
-
end
|
|
932
|
-
end)
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
---
|
|
936
|
-
|
|
937
|
-
## 10. Complete Melee Combat System
|
|
938
|
-
|
|
939
|
-
This ties together the state machine, hitbox detection, damage calculation, and cooldowns into a working server-side combat handler.
|
|
940
|
-
|
|
941
|
-
```luau
|
|
942
|
-
--!strict
|
|
943
|
-
-- MeleeCombatServer.luau (Script in ServerScriptService)
|
|
944
|
-
|
|
945
|
-
local Players = game:GetService("Players")
|
|
946
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
947
|
-
local RunService = game:GetService("RunService")
|
|
948
|
-
|
|
949
|
-
local CombatStateMachine = require(ReplicatedStorage:WaitForChild("CombatStateMachine"))
|
|
950
|
-
local DamageCalculator = require(game.ServerScriptService:WaitForChild("DamageCalculator"))
|
|
951
|
-
local CooldownManager = require(game.ServerScriptService:WaitForChild("CooldownManager"))
|
|
952
|
-
|
|
953
|
-
-- RemoteEvents
|
|
954
|
-
local attackRemote = ReplicatedStorage:WaitForChild("AttackRemote") :: RemoteEvent
|
|
955
|
-
local blockRemote = ReplicatedStorage:WaitForChild("BlockRemote") :: RemoteEvent
|
|
956
|
-
local dodgeRemote = ReplicatedStorage:WaitForChild("DodgeRemote") :: RemoteEvent
|
|
957
|
-
local combatResultRemote = ReplicatedStorage:WaitForChild("CombatResultRemote") :: RemoteEvent
|
|
958
|
-
local cooldownRemote = ReplicatedStorage:WaitForChild("CooldownStarted") :: RemoteEvent
|
|
959
|
-
|
|
960
|
-
-- Per-player state
|
|
961
|
-
local playerStateMachines: {[Player]: typeof(CombatStateMachine.new(nil :: any))} = {}
|
|
962
|
-
|
|
963
|
-
-- Weapon config (in production, load from a data module)
|
|
964
|
-
local DEFAULT_WEAPON: DamageCalculator.WeaponStats = {
|
|
965
|
-
baseDamage = 20,
|
|
966
|
-
weaponMultiplier = 1.0,
|
|
967
|
-
damageType = "Physical",
|
|
968
|
-
critChance = 0.1,
|
|
969
|
-
critMultiplier = 1.5,
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
local DEFAULT_STATS: DamageCalculator.CombatStats = {
|
|
973
|
-
attackBuff = 0,
|
|
974
|
-
defense = 0,
|
|
975
|
-
resistances = {},
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
local ATTACK_COOLDOWN = 0.3
|
|
979
|
-
local DODGE_INVULN_TAG = "DodgeInvuln"
|
|
980
|
-
|
|
981
|
-
-- Hitbox detection
|
|
982
|
-
local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model}
|
|
983
|
-
local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2)
|
|
984
|
-
local size = Vector3.new(width, height, range)
|
|
985
|
-
|
|
986
|
-
local overlapParams = OverlapParams.new()
|
|
987
|
-
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
988
|
-
overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent }
|
|
989
|
-
|
|
990
|
-
local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams)
|
|
991
|
-
|
|
992
|
-
local hitCharacters: {[Model]: true} = {}
|
|
993
|
-
local results: {Model} = {}
|
|
994
|
-
|
|
995
|
-
for _, part in parts do
|
|
996
|
-
local model = part:FindFirstAncestorWhichIsA("Model")
|
|
997
|
-
if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then
|
|
998
|
-
hitCharacters[model] = true
|
|
999
|
-
table.insert(results, model)
|
|
1000
|
-
end
|
|
1001
|
-
end
|
|
1002
|
-
|
|
1003
|
-
return results
|
|
1004
|
-
end
|
|
1005
|
-
|
|
1006
|
-
-- Knockback helper
|
|
1007
|
-
local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number)
|
|
1008
|
-
local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
|
|
1009
|
-
if not attachment then
|
|
1010
|
-
return
|
|
1011
|
-
end
|
|
1012
|
-
|
|
1013
|
-
local linearVelocity = Instance.new("LinearVelocity")
|
|
1014
|
-
linearVelocity.Attachment0 = attachment
|
|
1015
|
-
linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
|
|
1016
|
-
linearVelocity.MaxForce = math.huge
|
|
1017
|
-
linearVelocity.VectorVelocity = direction.Unit * force
|
|
1018
|
-
linearVelocity.Parent = targetRootPart
|
|
1019
|
-
|
|
1020
|
-
task.delay(0.3, function()
|
|
1021
|
-
linearVelocity:Destroy()
|
|
1022
|
-
end)
|
|
1023
|
-
end
|
|
1024
|
-
|
|
1025
|
-
-- Parry check
|
|
1026
|
-
local PARRY_WINDOW = 0.15
|
|
1027
|
-
|
|
1028
|
-
local function isParry(defenderSM: typeof(CombatStateMachine.new(nil :: any))): boolean
|
|
1029
|
-
if defenderSM:getState() ~= "Blocking" then
|
|
1030
|
-
return false
|
|
1031
|
-
end
|
|
1032
|
-
return (os.clock() - defenderSM.context.stateStartTime) <= PARRY_WINDOW
|
|
1033
|
-
end
|
|
1034
|
-
|
|
1035
|
-
-- Get state machine for a character model (NPC or player)
|
|
1036
|
-
local function getStateMachineForCharacter(character: Model): typeof(CombatStateMachine.new(nil :: any))?
|
|
1037
|
-
local player = Players:GetPlayerFromCharacter(character)
|
|
1038
|
-
if player then
|
|
1039
|
-
return playerStateMachines[player]
|
|
1040
|
-
end
|
|
1041
|
-
return nil
|
|
1042
|
-
end
|
|
1043
|
-
|
|
1044
|
-
-- Handle attack
|
|
1045
|
-
local function onAttack(player: Player)
|
|
1046
|
-
local sm = playerStateMachines[player]
|
|
1047
|
-
if not sm then
|
|
1048
|
-
return
|
|
1049
|
-
end
|
|
1050
|
-
|
|
1051
|
-
-- Cooldown check
|
|
1052
|
-
if not CooldownManager.isReady(player, "Attack") then
|
|
1053
|
-
return
|
|
1054
|
-
end
|
|
1055
|
-
|
|
1056
|
-
-- State machine check
|
|
1057
|
-
if not sm:tryAttack() then
|
|
1058
|
-
return
|
|
1059
|
-
end
|
|
1060
|
-
|
|
1061
|
-
CooldownManager.startCooldown(player, "Attack", ATTACK_COOLDOWN)
|
|
1062
|
-
|
|
1063
|
-
-- Get character
|
|
1064
|
-
local character = player.Character
|
|
1065
|
-
if not character then
|
|
1066
|
-
return
|
|
1067
|
-
end
|
|
1068
|
-
local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
|
|
1069
|
-
if not rootPart then
|
|
1070
|
-
return
|
|
1071
|
-
end
|
|
1072
|
-
|
|
1073
|
-
-- Hitbox detection
|
|
1074
|
-
local comboHit = sm:getComboCount()
|
|
1075
|
-
local range = if comboHit >= 4 then 9 else 7
|
|
1076
|
-
local width = if comboHit >= 3 then 7 else 5
|
|
1077
|
-
local targets = getHitTargets(rootPart, range, width, 6)
|
|
1078
|
-
|
|
1079
|
-
for _, targetModel in targets do
|
|
1080
|
-
local targetHumanoid = targetModel:FindFirstChildWhichIsA("Humanoid")
|
|
1081
|
-
if not targetHumanoid or targetHumanoid.Health <= 0 then
|
|
1082
|
-
continue
|
|
1083
|
-
end
|
|
1084
|
-
|
|
1085
|
-
-- Check dodge invulnerability
|
|
1086
|
-
if targetModel:FindFirstChild(DODGE_INVULN_TAG) then
|
|
1087
|
-
continue
|
|
1088
|
-
end
|
|
1089
|
-
|
|
1090
|
-
-- Check parry
|
|
1091
|
-
local targetSM = getStateMachineForCharacter(targetModel)
|
|
1092
|
-
if targetSM and isParry(targetSM) then
|
|
1093
|
-
-- Parry: stun the attacker instead
|
|
1094
|
-
sm:applyStun(1.2)
|
|
1095
|
-
combatResultRemote:FireClient(player, "Parried", targetModel)
|
|
1096
|
-
|
|
1097
|
-
local targetPlayer = Players:GetPlayerFromCharacter(targetModel)
|
|
1098
|
-
if targetPlayer then
|
|
1099
|
-
combatResultRemote:FireClient(targetPlayer, "ParrySuccess", character)
|
|
1100
|
-
end
|
|
1101
|
-
continue
|
|
1102
|
-
end
|
|
1103
|
-
|
|
1104
|
-
-- Calculate damage
|
|
1105
|
-
local isBlocking = if targetSM then targetSM:getState() == "Blocking" else false
|
|
1106
|
-
local attackerStats = DEFAULT_STATS -- replace with real stats lookup
|
|
1107
|
-
local defenderStats = DEFAULT_STATS -- replace with real stats lookup
|
|
1108
|
-
|
|
1109
|
-
local result = DamageCalculator.calculate(DEFAULT_WEAPON, attackerStats, defenderStats, comboHit, isBlocking)
|
|
1110
|
-
targetHumanoid:TakeDamage(result.finalDamage)
|
|
1111
|
-
|
|
1112
|
-
-- Knockback on combo finisher
|
|
1113
|
-
local targetRootPart = targetModel:FindFirstChild("HumanoidRootPart") :: BasePart?
|
|
1114
|
-
if comboHit >= 4 and targetRootPart then
|
|
1115
|
-
local knockDir = (targetRootPart.Position - rootPart.Position)
|
|
1116
|
-
applyKnockback(targetRootPart, knockDir, 50)
|
|
1117
|
-
end
|
|
1118
|
-
|
|
1119
|
-
-- Notify all relevant clients
|
|
1120
|
-
combatResultRemote:FireAllClients("DamageDealt", {
|
|
1121
|
-
target = targetModel,
|
|
1122
|
-
amount = result.finalDamage,
|
|
1123
|
-
isCritical = result.isCritical,
|
|
1124
|
-
blocked = result.blocked,
|
|
1125
|
-
comboHit = comboHit,
|
|
1126
|
-
})
|
|
1127
|
-
end
|
|
1128
|
-
end
|
|
1129
|
-
|
|
1130
|
-
-- Handle block
|
|
1131
|
-
local function onBlock(player: Player, isBlocking: boolean)
|
|
1132
|
-
local sm = playerStateMachines[player]
|
|
1133
|
-
if not sm then
|
|
1134
|
-
return
|
|
1135
|
-
end
|
|
1136
|
-
|
|
1137
|
-
if isBlocking then
|
|
1138
|
-
sm:tryBlock()
|
|
1139
|
-
else
|
|
1140
|
-
sm:releaseBlock()
|
|
1141
|
-
end
|
|
1142
|
-
end
|
|
1143
|
-
|
|
1144
|
-
-- Handle dodge
|
|
1145
|
-
local function onDodge(player: Player)
|
|
1146
|
-
local sm = playerStateMachines[player]
|
|
1147
|
-
if not sm then
|
|
1148
|
-
return
|
|
1149
|
-
end
|
|
1150
|
-
|
|
1151
|
-
if not sm:tryDodge() then
|
|
1152
|
-
return
|
|
1153
|
-
end
|
|
1154
|
-
|
|
1155
|
-
local character = player.Character
|
|
1156
|
-
if not character then
|
|
1157
|
-
return
|
|
1158
|
-
end
|
|
1159
|
-
local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
|
|
1160
|
-
if not rootPart then
|
|
1161
|
-
return
|
|
1162
|
-
end
|
|
1163
|
-
|
|
1164
|
-
-- Add invulnerability tag
|
|
1165
|
-
local tag = Instance.new("BoolValue")
|
|
1166
|
-
tag.Name = DODGE_INVULN_TAG
|
|
1167
|
-
tag.Parent = character
|
|
1168
|
-
|
|
1169
|
-
-- Apply dodge movement
|
|
1170
|
-
local moveDir = rootPart.CFrame.LookVector
|
|
1171
|
-
applyKnockback(rootPart, moveDir, 60)
|
|
1172
|
-
|
|
1173
|
-
-- Notify client for cooldown display
|
|
1174
|
-
cooldownRemote:FireClient(player, "Dodge", 1.5)
|
|
1175
|
-
|
|
1176
|
-
-- Remove tag after dodge duration
|
|
1177
|
-
task.delay(0.5, function()
|
|
1178
|
-
tag:Destroy()
|
|
1179
|
-
end)
|
|
1180
|
-
end
|
|
1181
|
-
|
|
1182
|
-
-- Player setup / teardown
|
|
1183
|
-
Players.PlayerAdded:Connect(function(player: Player)
|
|
1184
|
-
CooldownManager.initialize(player)
|
|
1185
|
-
|
|
1186
|
-
player.CharacterAdded:Connect(function()
|
|
1187
|
-
local sm = CombatStateMachine.new(player)
|
|
1188
|
-
playerStateMachines[player] = sm
|
|
1189
|
-
end)
|
|
1190
|
-
|
|
1191
|
-
player.CharacterRemoving:Connect(function()
|
|
1192
|
-
local sm = playerStateMachines[player]
|
|
1193
|
-
if sm then
|
|
1194
|
-
sm:destroy()
|
|
1195
|
-
playerStateMachines[player] = nil
|
|
1196
|
-
end
|
|
1197
|
-
end)
|
|
1198
|
-
end)
|
|
1199
|
-
|
|
1200
|
-
Players.PlayerRemoving:Connect(function(player: Player)
|
|
1201
|
-
CooldownManager.cleanup(player)
|
|
1202
|
-
local sm = playerStateMachines[player]
|
|
1203
|
-
if sm then
|
|
1204
|
-
sm:destroy()
|
|
1205
|
-
playerStateMachines[player] = nil
|
|
1206
|
-
end
|
|
1207
|
-
end)
|
|
1208
|
-
|
|
1209
|
-
-- Update state machines every frame
|
|
1210
|
-
RunService.Heartbeat:Connect(function()
|
|
1211
|
-
for _, sm in playerStateMachines do
|
|
1212
|
-
sm:update()
|
|
1213
|
-
end
|
|
1214
|
-
end)
|
|
1215
|
-
|
|
1216
|
-
-- Connect remotes
|
|
1217
|
-
attackRemote.OnServerEvent:Connect(onAttack)
|
|
1218
|
-
blockRemote.OnServerEvent:Connect(function(player: Player, isBlocking: boolean)
|
|
1219
|
-
if typeof(isBlocking) ~= "boolean" then
|
|
1220
|
-
return -- type validation
|
|
1221
|
-
end
|
|
1222
|
-
onBlock(player, isBlocking)
|
|
1223
|
-
end)
|
|
1224
|
-
dodgeRemote.OnServerEvent:Connect(onDodge)
|
|
1225
|
-
```
|
|
1226
|
-
|
|
1227
|
-
### Client-Side Input Handler
|
|
1228
|
-
|
|
1229
|
-
```luau
|
|
1230
|
-
--!strict
|
|
1231
|
-
-- CombatInput.luau (LocalScript in StarterPlayerScripts)
|
|
1232
|
-
|
|
1233
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
1234
|
-
local UserInputService = game:GetService("UserInputService")
|
|
1235
|
-
|
|
1236
|
-
local attackRemote = ReplicatedStorage:WaitForChild("AttackRemote") :: RemoteEvent
|
|
1237
|
-
local blockRemote = ReplicatedStorage:WaitForChild("BlockRemote") :: RemoteEvent
|
|
1238
|
-
local dodgeRemote = ReplicatedStorage:WaitForChild("DodgeRemote") :: RemoteEvent
|
|
1239
|
-
|
|
1240
|
-
-- Attack: left mouse click
|
|
1241
|
-
UserInputService.InputBegan:Connect(function(input: InputObject, gameProcessed: boolean)
|
|
1242
|
-
if gameProcessed then
|
|
1243
|
-
return
|
|
1244
|
-
end
|
|
1245
|
-
|
|
1246
|
-
if input.UserInputType == Enum.UserInputType.MouseButton1 then
|
|
1247
|
-
attackRemote:FireServer()
|
|
1248
|
-
elseif input.KeyCode == Enum.KeyCode.F then
|
|
1249
|
-
blockRemote:FireServer(true)
|
|
1250
|
-
elseif input.KeyCode == Enum.KeyCode.Q then
|
|
1251
|
-
dodgeRemote:FireServer()
|
|
1252
|
-
end
|
|
1253
|
-
end)
|
|
1254
|
-
|
|
1255
|
-
-- Release block
|
|
1256
|
-
UserInputService.InputEnded:Connect(function(input: InputObject, gameProcessed: boolean)
|
|
1257
|
-
if input.KeyCode == Enum.KeyCode.F then
|
|
1258
|
-
blockRemote:FireServer(false)
|
|
1259
|
-
end
|
|
1260
|
-
end)
|
|
1261
|
-
```
|
|
1262
|
-
|
|
1263
|
-
---
|
|
1264
|
-
|
|
1265
|
-
## 11. Best Practices
|
|
1266
|
-
|
|
1267
|
-
### Server Authority
|
|
1268
|
-
|
|
1269
|
-
- **All damage is applied server-side.** The client never calls `Humanoid:TakeDamage()`.
|
|
1270
|
-
- **Validate every RemoteEvent argument.** Check `typeof()`, clamp numeric ranges, verify the player owns the weapon they claim to use.
|
|
1271
|
-
- **Run hitbox detection on the server** using the server's known character positions, not client-reported positions.
|
|
1272
|
-
|
|
1273
|
-
### Hit Registration Integrity
|
|
1274
|
-
|
|
1275
|
-
- Use `OverlapParams.FilterDescendantsInstances` to exclude the attacker's own character from hitbox results.
|
|
1276
|
-
- Deduplicate hits: track which characters were already hit during a single attack swing to prevent multi-hit exploits.
|
|
1277
|
-
- Limit hit targets per swing (e.g., max 5) to prevent AoE exploits.
|
|
1278
|
-
|
|
1279
|
-
### Fair PvP
|
|
1280
|
-
|
|
1281
|
-
- Normalize base stats in PvP arenas so gear differences don't create unfair advantages, or use matchmaking tiers.
|
|
1282
|
-
- Telegraph attacks: give enemies visual/audio cues (wind-up animation, sound effect) before damage lands so they can react.
|
|
1283
|
-
- Balance risk vs. reward: powerful attacks should have longer recovery, wider parry windows, or more telegraphing.
|
|
1284
|
-
|
|
1285
|
-
### Performance
|
|
1286
|
-
|
|
1287
|
-
- Don't create new `OverlapParams` / `RaycastParams` every frame. Create once, reuse, update the filter list as needed.
|
|
1288
|
-
- Pool damage number BillboardGuis instead of creating/destroying them constantly.
|
|
1289
|
-
- For large-scale battles (20+ players), consider spatial partitioning to limit hitbox checks to nearby characters only.
|
|
1290
|
-
|
|
1291
|
-
---
|
|
1292
|
-
|
|
1293
|
-
## 12. Anti-Patterns
|
|
1294
|
-
|
|
1295
|
-
### Client-Side Damage Calculation
|
|
1296
|
-
|
|
1297
|
-
```luau
|
|
1298
|
-
-- BAD: Client decides damage and tells server
|
|
1299
|
-
attackRemote:FireServer(targetPlayer, 9999) -- exploiter sends any number
|
|
1300
|
-
|
|
1301
|
-
-- GOOD: Client sends intent, server calculates
|
|
1302
|
-
attackRemote:FireServer() -- server determines targets and damage
|
|
1303
|
-
```
|
|
1304
|
-
|
|
1305
|
-
### Trusting Client Hitbox Results
|
|
1306
|
-
|
|
1307
|
-
```luau
|
|
1308
|
-
-- BAD: Client reports who it hit
|
|
1309
|
-
attackRemote:FireServer(hitTargets) -- exploiter sends all players on server
|
|
1310
|
-
|
|
1311
|
-
-- GOOD: Server runs its own hitbox detection
|
|
1312
|
-
-- Client sends nothing except "I attacked"
|
|
1313
|
-
```
|
|
1314
|
-
|
|
1315
|
-
### No Cooldown Enforcement
|
|
1316
|
-
|
|
1317
|
-
```luau
|
|
1318
|
-
-- BAD: Only client checks cooldowns (exploiter removes the check)
|
|
1319
|
-
-- BAD: Server tracks cooldown but doesn't reject early attacks
|
|
1320
|
-
|
|
1321
|
-
-- GOOD: Server rejects and silently drops requests that violate cooldowns
|
|
1322
|
-
if not CooldownManager.isReady(player, "Attack") then
|
|
1323
|
-
return -- silently ignore, don't send error messages exploiters can use
|
|
1324
|
-
end
|
|
1325
|
-
```
|
|
1326
|
-
|
|
1327
|
-
### No State Validation
|
|
1328
|
-
|
|
1329
|
-
```luau
|
|
1330
|
-
-- BAD: Allow attacking while stunned, dead, or in menus
|
|
1331
|
-
-- BAD: Allow blocking and attacking simultaneously
|
|
1332
|
-
|
|
1333
|
-
-- GOOD: State machine enforces valid transitions
|
|
1334
|
-
if not stateMachine:canTransition("Attacking") then
|
|
1335
|
-
return
|
|
1336
|
-
end
|
|
1337
|
-
```
|
|
1338
|
-
|
|
1339
|
-
### Instant Unavoidable Attacks
|
|
1340
|
-
|
|
1341
|
-
- Every attack should have a counter: dodging, blocking, parrying, or spacing.
|
|
1342
|
-
- If an attack is instant, it should deal low damage. If it deals high damage, it should be telegraphed and avoidable.
|
|
1343
|
-
- Avoid homing projectiles that are both fast and undodgeable.
|
|
1344
|
-
|
|
1345
|
-
### Floating Point Stat Stacking
|
|
1346
|
-
|
|
1347
|
-
```luau
|
|
1348
|
-
-- BAD: uncapped buff stacking
|
|
1349
|
-
totalBuff = totalBuff + newBuff -- can exceed 100%, goes to infinity
|
|
1350
|
-
|
|
1351
|
-
-- GOOD: clamp all multipliers
|
|
1352
|
-
totalBuff = math.clamp(totalBuff + newBuff, 0, 1.0) -- cap at 100% buff
|
|
1353
|
-
defense = math.clamp(defense, 0, 0.9) -- cap at 90% reduction, minimum damage always gets through
|
|
1354
|
-
```
|
|
1355
|
-
|
|
1356
|
-
---
|
|
1357
|
-
|
|
1358
|
-
## Quick Reference: Required RemoteEvents
|
|
1359
|
-
|
|
1360
|
-
Create these in ReplicatedStorage for the complete system:
|
|
1361
|
-
|
|
1362
|
-
| RemoteEvent Name | Direction | Purpose |
|
|
1363
|
-
|---|---|---|
|
|
1364
|
-
| `AttackRemote` | Client -> Server | Player pressed attack |
|
|
1365
|
-
| `BlockRemote` | Client -> Server | Player started/stopped blocking |
|
|
1366
|
-
| `DodgeRemote` | Client -> Server | Player pressed dodge |
|
|
1367
|
-
| `CombatResultRemote` | Server -> Client | Damage dealt, parry, etc. for VFX |
|
|
1368
|
-
| `CooldownStarted` | Server -> Client | Ability cooldown began (for UI) |
|
|
1369
|
-
|
|
1370
|
-
---
|
|
1371
|
-
|
|
1372
|
-
## Quick Reference: Module Locations
|
|
1373
|
-
|
|
1374
|
-
| Module | Location | Purpose |
|
|
1375
|
-
|---|---|---|
|
|
1376
|
-
| `CombatStateMachine` | ReplicatedStorage | State machine (shared types) |
|
|
1377
|
-
| `DamageCalculator` | ServerScriptService | Damage formula (server only) |
|
|
1378
|
-
| `CooldownManager` | ServerScriptService | Cooldown tracking (server only) |
|
|
1379
|
-
| `DamageDisplay` | ReplicatedStorage | Floating damage numbers (client) |
|
|
1380
|
-
| `MeleeCombatServer` | ServerScriptService | Main combat handler (server) |
|
|
1381
|
-
| `CombatInput` | StarterPlayerScripts | Input -> RemoteEvent (client) |
|
|
1
|
+
# Combat Systems Reference
|
|
2
|
+
|
|
3
|
+
> **Load when:** Building combat, weapons, PvP, PvE, damage systems, hitboxes, melee/ranged attacks, combo systems, blocking/parrying, cooldowns, or skill-based combat.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Overview
|
|
8
|
+
|
|
9
|
+
Combat is one of the most exploited systems in Roblox. Every design decision must start from the principle: **the server is the sole authority on damage, health, and combat state**. The client's job is to send *intent* ("I pressed attack"), show responsive animations/VFX, and display server-confirmed results.
|
|
10
|
+
|
|
11
|
+
This reference covers:
|
|
12
|
+
|
|
13
|
+
- Server-authoritative architecture
|
|
14
|
+
- Hitbox detection (spatial queries and raycasts)
|
|
15
|
+
- Combat state machines
|
|
16
|
+
- Damage calculation formulas
|
|
17
|
+
- Melee and ranged combat patterns
|
|
18
|
+
- Cooldown enforcement
|
|
19
|
+
- Anti-exploit hardening
|
|
20
|
+
|
|
21
|
+
All code examples are production-grade Luau. Adapt to your game's scale.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 2. Combat Architecture
|
|
26
|
+
|
|
27
|
+
### The Golden Rule
|
|
28
|
+
|
|
29
|
+
> The client sends **intent**. The server **validates and applies**.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Client Server
|
|
33
|
+
------ ------
|
|
34
|
+
Player presses attack
|
|
35
|
+
|
|
|
36
|
+
+--> FireServer("Attack")
|
|
37
|
+
|
|
|
38
|
+
+--> Validate cooldown
|
|
39
|
+
+--> Validate state (not stunned, not dead)
|
|
40
|
+
+--> Run hitbox detection
|
|
41
|
+
+--> Calculate damage
|
|
42
|
+
+--> Apply damage to targets
|
|
43
|
+
+--> Update attacker state/cooldowns
|
|
44
|
+
|
|
|
45
|
+
<-- FireClient(results) ------+
|
|
46
|
+
|
|
|
47
|
+
+--> Play hit VFX/SFX
|
|
48
|
+
+--> Show damage numbers
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Why Not Client-Authoritative?
|
|
52
|
+
|
|
53
|
+
Exploiters can modify LocalScripts, fire RemoteEvents with fabricated data, and teleport their character. If the client decides who gets hit or how much damage to deal, a single exploiter ruins the entire server.
|
|
54
|
+
|
|
55
|
+
### Latency Compensation
|
|
56
|
+
|
|
57
|
+
For fast-paced combat, pure server-side hitboxes can feel unresponsive. Two strategies:
|
|
58
|
+
|
|
59
|
+
1. **Predictive VFX** -- Client plays the swing animation and VFX immediately on input. Server confirms the hit separately. If the server says "miss," the client shows no damage numbers. Players perceive responsiveness from the animation even if the server takes 50-100ms to confirm.
|
|
60
|
+
|
|
61
|
+
2. **Client hint with server validation** -- Client sends its estimated hit targets along with the attack request. Server re-runs the hitbox check using the character's server-side position. If the server's check agrees, damage applies. If not, the client hint is discarded. This catches teleport exploits while tolerating minor positional lag.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 3. Hitbox Detection
|
|
66
|
+
|
|
67
|
+
### Area Detection (Melee / AoE)
|
|
68
|
+
|
|
69
|
+
Use `workspace:GetPartBoundsInBox()` for volume-based detection:
|
|
70
|
+
|
|
71
|
+
```luau
|
|
72
|
+
local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model}
|
|
73
|
+
local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2)
|
|
74
|
+
local size = Vector3.new(width, height, range)
|
|
75
|
+
|
|
76
|
+
local overlapParams = OverlapParams.new()
|
|
77
|
+
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
78
|
+
overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent }
|
|
79
|
+
|
|
80
|
+
local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams)
|
|
81
|
+
|
|
82
|
+
local hitCharacters: {[Model]: true} = {}
|
|
83
|
+
local results: {Model} = {}
|
|
84
|
+
|
|
85
|
+
for _, part in parts do
|
|
86
|
+
local model = part:FindFirstAncestorWhichIsA("Model")
|
|
87
|
+
if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then
|
|
88
|
+
hitCharacters[model] = true
|
|
89
|
+
table.insert(results, model)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return results
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Alternative: `GetPartsInPart`
|
|
98
|
+
|
|
99
|
+
When you have a physical hitbox part (e.g., a sword blade):
|
|
100
|
+
|
|
101
|
+
```luau
|
|
102
|
+
local overlapParams = OverlapParams.new()
|
|
103
|
+
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
104
|
+
overlapParams.FilterDescendantsInstances = { swordModel }
|
|
105
|
+
|
|
106
|
+
local touchingParts = workspace:GetPartsInPart(hitboxPart, overlapParams)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Raycast Detection (Ranged / Hitscan)
|
|
110
|
+
|
|
111
|
+
```luau
|
|
112
|
+
local function hitscanRaycast(origin: Vector3, direction: Vector3, ignoreList: {Instance}): RaycastResult?
|
|
113
|
+
local raycastParams = RaycastParams.new()
|
|
114
|
+
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
115
|
+
raycastParams.FilterDescendantsInstances = ignoreList
|
|
116
|
+
|
|
117
|
+
return workspace:Raycast(origin, direction, raycastParams)
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Hitbox Sizing Guidelines
|
|
122
|
+
|
|
123
|
+
| Weapon Type | Range (studs) | Width (studs) | Height (studs) |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| Dagger / Fist | 4-5 | 4 | 5 |
|
|
126
|
+
| Sword | 6-8 | 5 | 6 |
|
|
127
|
+
| Greatsword | 8-12 | 7 | 7 |
|
|
128
|
+
| Spear / Polearm | 10-14 | 3 | 5 |
|
|
129
|
+
| AoE Slam | 8-10 | 10 | 8 |
|
|
130
|
+
|
|
131
|
+
Position the hitbox CFrame in front of the character's HumanoidRootPart. Offset by half the range on the Z axis so the box starts at the character and extends forward.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 4. State Machines
|
|
136
|
+
|
|
137
|
+
Combat states prevent invalid action combinations (e.g., attacking while stunned) and enforce recovery windows.
|
|
138
|
+
|
|
139
|
+
### State Diagram
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
+-----------+
|
|
143
|
+
+------>| Idle |<------+
|
|
144
|
+
| +-----+-----+ |
|
|
145
|
+
| | |
|
|
146
|
+
(recovery (attack (block
|
|
147
|
+
expires) input) input)
|
|
148
|
+
| | |
|
|
149
|
+
| +-----v-----+ |
|
|
150
|
+
| | Attacking | |
|
|
151
|
+
| +-----+-----+ |
|
|
152
|
+
| | |
|
|
153
|
+
| (attack +---+----+
|
|
154
|
+
| ends) | Blocking|
|
|
155
|
+
| | +---+----+
|
|
156
|
+
| +-----v-----+ |
|
|
157
|
+
+-------+ Recovery | |
|
|
158
|
+
| +-----------+ (release
|
|
159
|
+
| block)
|
|
160
|
+
| |
|
|
161
|
+
+----+-----+ +-----v-----+
|
|
162
|
+
| Stunned +----------->| Idle |
|
|
163
|
+
+----------+ (stun +-----------+
|
|
164
|
+
expires)
|
|
165
|
+
|
|
166
|
+
Dodge can interrupt Idle or Blocking:
|
|
167
|
+
Idle/Blocking --> Dodging --> Idle
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Complete State Machine Implementation
|
|
171
|
+
|
|
172
|
+
```luau
|
|
173
|
+
--!strict
|
|
174
|
+
-- CombatStateMachine.luau (ModuleScript in ReplicatedStorage)
|
|
175
|
+
|
|
176
|
+
export type CombatState = "Idle" | "Attacking" | "Recovery" | "Blocking" | "Dodging" | "Stunned"
|
|
177
|
+
|
|
178
|
+
export type StateTransition = {
|
|
179
|
+
from: {CombatState},
|
|
180
|
+
to: CombatState,
|
|
181
|
+
condition: ((context: StateMachineContext) -> boolean)?,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type StateMachineContext = {
|
|
185
|
+
player: Player,
|
|
186
|
+
currentState: CombatState,
|
|
187
|
+
stateStartTime: number,
|
|
188
|
+
lastAttackTime: number,
|
|
189
|
+
comboCount: number,
|
|
190
|
+
stunEndTime: number,
|
|
191
|
+
dodgeCooldownEnd: number,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
local CombatStateMachine = {}
|
|
195
|
+
CombatStateMachine.__index = CombatStateMachine
|
|
196
|
+
|
|
197
|
+
local RECOVERY_DURATION = 0.4
|
|
198
|
+
local DODGE_DURATION = 0.5
|
|
199
|
+
local DODGE_COOLDOWN = 1.5
|
|
200
|
+
local STUN_DEFAULT_DURATION = 1.0
|
|
201
|
+
local ATTACK_DURATION = 0.35
|
|
202
|
+
local COMBO_WINDOW = 0.8
|
|
203
|
+
local MAX_COMBO = 4
|
|
204
|
+
|
|
205
|
+
local VALID_TRANSITIONS: {StateTransition} = {
|
|
206
|
+
{ from = { "Idle" }, to = "Attacking" },
|
|
207
|
+
{ from = { "Attacking" }, to = "Recovery" },
|
|
208
|
+
{ from = { "Recovery" }, to = "Idle" },
|
|
209
|
+
{ from = { "Recovery" }, to = "Attacking" }, -- combo: attack during recovery window
|
|
210
|
+
{ from = { "Idle" }, to = "Blocking" },
|
|
211
|
+
{ from = { "Blocking" }, to = "Idle" },
|
|
212
|
+
{ from = { "Idle", "Blocking" }, to = "Dodging" },
|
|
213
|
+
{ from = { "Dodging" }, to = "Idle" },
|
|
214
|
+
{ from = { "Idle", "Attacking", "Recovery", "Blocking", "Dodging" }, to = "Stunned" },
|
|
215
|
+
{ from = { "Stunned" }, to = "Idle" },
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function CombatStateMachine.new(player: Player)
|
|
219
|
+
local self = setmetatable({}, CombatStateMachine)
|
|
220
|
+
self.context = {
|
|
221
|
+
player = player,
|
|
222
|
+
currentState = "Idle" :: CombatState,
|
|
223
|
+
stateStartTime = os.clock(),
|
|
224
|
+
lastAttackTime = 0,
|
|
225
|
+
comboCount = 0,
|
|
226
|
+
stunEndTime = 0,
|
|
227
|
+
dodgeCooldownEnd = 0,
|
|
228
|
+
} :: StateMachineContext
|
|
229
|
+
self._onStateChanged = {} :: {(CombatState, CombatState) -> ()}
|
|
230
|
+
return self
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
function CombatStateMachine:getState(): CombatState
|
|
234
|
+
return self.context.currentState
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
function CombatStateMachine:getComboCount(): number
|
|
238
|
+
return self.context.comboCount
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
function CombatStateMachine:onStateChanged(callback: (CombatState, CombatState) -> ())
|
|
242
|
+
table.insert(self._onStateChanged, callback)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
function CombatStateMachine:canTransition(to: CombatState): boolean
|
|
246
|
+
local from = self.context.currentState
|
|
247
|
+
|
|
248
|
+
for _, transition in VALID_TRANSITIONS do
|
|
249
|
+
if transition.to == to and table.find(transition.from, from) then
|
|
250
|
+
if transition.condition and not transition.condition(self.context) then
|
|
251
|
+
continue
|
|
252
|
+
end
|
|
253
|
+
return true
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
return false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
function CombatStateMachine:transition(to: CombatState): boolean
|
|
261
|
+
if not self:canTransition(to) then
|
|
262
|
+
return false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
local from = self.context.currentState
|
|
266
|
+
self.context.currentState = to
|
|
267
|
+
self.context.stateStartTime = os.clock()
|
|
268
|
+
|
|
269
|
+
for _, callback in self._onStateChanged do
|
|
270
|
+
callback(from, to)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
return true
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
function CombatStateMachine:tryAttack(): boolean
|
|
277
|
+
local now = os.clock()
|
|
278
|
+
local ctx = self.context
|
|
279
|
+
|
|
280
|
+
-- Combo: if in Recovery and within combo window, allow chaining
|
|
281
|
+
if ctx.currentState == "Recovery" then
|
|
282
|
+
local timeSinceAttack = now - ctx.lastAttackTime
|
|
283
|
+
if timeSinceAttack <= COMBO_WINDOW and ctx.comboCount < MAX_COMBO then
|
|
284
|
+
ctx.comboCount += 1
|
|
285
|
+
ctx.lastAttackTime = now
|
|
286
|
+
return self:transition("Attacking")
|
|
287
|
+
end
|
|
288
|
+
return false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if ctx.currentState ~= "Idle" then
|
|
292
|
+
return false
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
ctx.comboCount = 1
|
|
296
|
+
ctx.lastAttackTime = now
|
|
297
|
+
return self:transition("Attacking")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
function CombatStateMachine:tryBlock(): boolean
|
|
301
|
+
return self:transition("Blocking")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
function CombatStateMachine:releaseBlock(): boolean
|
|
305
|
+
if self.context.currentState ~= "Blocking" then
|
|
306
|
+
return false
|
|
307
|
+
end
|
|
308
|
+
return self:transition("Idle")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
function CombatStateMachine:tryDodge(): boolean
|
|
312
|
+
local now = os.clock()
|
|
313
|
+
if now < self.context.dodgeCooldownEnd then
|
|
314
|
+
return false
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if self:transition("Dodging") then
|
|
318
|
+
self.context.dodgeCooldownEnd = now + DODGE_COOLDOWN
|
|
319
|
+
return true
|
|
320
|
+
end
|
|
321
|
+
return false
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
function CombatStateMachine:applyStun(duration: number?)
|
|
325
|
+
local stunDuration = duration or STUN_DEFAULT_DURATION
|
|
326
|
+
self.context.stunEndTime = os.clock() + stunDuration
|
|
327
|
+
self:transition("Stunned")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
function CombatStateMachine:update()
|
|
331
|
+
local now = os.clock()
|
|
332
|
+
local ctx = self.context
|
|
333
|
+
local elapsed = now - ctx.stateStartTime
|
|
334
|
+
|
|
335
|
+
if ctx.currentState == "Attacking" and elapsed >= ATTACK_DURATION then
|
|
336
|
+
self:transition("Recovery")
|
|
337
|
+
elseif ctx.currentState == "Recovery" and elapsed >= RECOVERY_DURATION then
|
|
338
|
+
ctx.comboCount = 0
|
|
339
|
+
self:transition("Idle")
|
|
340
|
+
elseif ctx.currentState == "Dodging" and elapsed >= DODGE_DURATION then
|
|
341
|
+
self:transition("Idle")
|
|
342
|
+
elseif ctx.currentState == "Stunned" and now >= ctx.stunEndTime then
|
|
343
|
+
self:transition("Idle")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
function CombatStateMachine:destroy()
|
|
348
|
+
table.clear(self._onStateChanged)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
return CombatStateMachine
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Tuning Constants
|
|
355
|
+
|
|
356
|
+
| Constant | Default | Effect |
|
|
357
|
+
|---|---|---|
|
|
358
|
+
| `ATTACK_DURATION` | 0.35s | How long the attack hitbox is active |
|
|
359
|
+
| `RECOVERY_DURATION` | 0.4s | Window after attack before returning to Idle |
|
|
360
|
+
| `COMBO_WINDOW` | 0.8s | Time after an attack during which the next attack counts as a combo |
|
|
361
|
+
| `MAX_COMBO` | 4 | Maximum consecutive hits in a combo chain |
|
|
362
|
+
| `DODGE_DURATION` | 0.5s | Invulnerability / movement duration |
|
|
363
|
+
| `DODGE_COOLDOWN` | 1.5s | Time between dodge uses |
|
|
364
|
+
| `STUN_DEFAULT_DURATION` | 1.0s | Default stun length |
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## 5. Damage Calculation
|
|
369
|
+
|
|
370
|
+
### Formula
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
finalDamage = baseDamage
|
|
374
|
+
* weaponMultiplier
|
|
375
|
+
* (1 + totalBuffPercent)
|
|
376
|
+
* (1 - defensePercent)
|
|
377
|
+
* critMultiplier
|
|
378
|
+
* comboMultiplier
|
|
379
|
+
* typeEffectiveness
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Implementation
|
|
383
|
+
|
|
384
|
+
```luau
|
|
385
|
+
--!strict
|
|
386
|
+
-- DamageCalculator.luau (ModuleScript in ServerScriptService)
|
|
387
|
+
|
|
388
|
+
export type DamageType = "Physical" | "Magical" | "Fire" | "Ice" | "Lightning"
|
|
389
|
+
|
|
390
|
+
export type WeaponStats = {
|
|
391
|
+
baseDamage: number,
|
|
392
|
+
weaponMultiplier: number,
|
|
393
|
+
damageType: DamageType,
|
|
394
|
+
critChance: number, -- 0.0 to 1.0
|
|
395
|
+
critMultiplier: number, -- e.g. 1.5 = 150% damage on crit
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export type CombatStats = {
|
|
399
|
+
attackBuff: number, -- 0.0 to 1.0 (e.g. 0.25 = 25% buff)
|
|
400
|
+
defense: number, -- 0.0 to 1.0 (e.g. 0.3 = 30% damage reduction)
|
|
401
|
+
resistances: {[DamageType]: number}, -- 0.0 to 1.0 per type
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export type DamageResult = {
|
|
405
|
+
rawDamage: number,
|
|
406
|
+
finalDamage: number,
|
|
407
|
+
isCritical: boolean,
|
|
408
|
+
damageType: DamageType,
|
|
409
|
+
blocked: boolean,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
local DamageCalculator = {}
|
|
413
|
+
|
|
414
|
+
local COMBO_MULTIPLIERS = { 1.0, 1.1, 1.25, 1.5 }
|
|
415
|
+
local BLOCK_REDUCTION = 0.8 -- blocking reduces damage by 80%
|
|
416
|
+
local MIN_DAMAGE = 1
|
|
417
|
+
|
|
418
|
+
function DamageCalculator.calculate(
|
|
419
|
+
weapon: WeaponStats,
|
|
420
|
+
attackerStats: CombatStats,
|
|
421
|
+
defenderStats: CombatStats,
|
|
422
|
+
comboHit: number,
|
|
423
|
+
isBlocking: boolean
|
|
424
|
+
): DamageResult
|
|
425
|
+
local baseDamage = weapon.baseDamage * weapon.weaponMultiplier
|
|
426
|
+
|
|
427
|
+
-- Buff multiplier
|
|
428
|
+
local buffMultiplier = 1 + attackerStats.attackBuff
|
|
429
|
+
|
|
430
|
+
-- Defense multiplier
|
|
431
|
+
local defenseMultiplier = 1 - math.clamp(defenderStats.defense, 0, 0.9) -- cap at 90%
|
|
432
|
+
|
|
433
|
+
-- Elemental resistance
|
|
434
|
+
local resistance = defenderStats.resistances[weapon.damageType] or 0
|
|
435
|
+
local typeMultiplier = 1 - math.clamp(resistance, 0, 0.9)
|
|
436
|
+
|
|
437
|
+
-- Critical hit
|
|
438
|
+
local isCritical = math.random() < weapon.critChance
|
|
439
|
+
local critMultiplier = if isCritical then weapon.critMultiplier else 1.0
|
|
440
|
+
|
|
441
|
+
-- Combo scaling
|
|
442
|
+
local comboIndex = math.clamp(comboHit, 1, #COMBO_MULTIPLIERS)
|
|
443
|
+
local comboMultiplier = COMBO_MULTIPLIERS[comboIndex]
|
|
444
|
+
|
|
445
|
+
-- Combine
|
|
446
|
+
local rawDamage = baseDamage * buffMultiplier * critMultiplier * comboMultiplier
|
|
447
|
+
local finalDamage = rawDamage * defenseMultiplier * typeMultiplier
|
|
448
|
+
|
|
449
|
+
-- Blocking
|
|
450
|
+
local blocked = false
|
|
451
|
+
if isBlocking then
|
|
452
|
+
finalDamage *= (1 - BLOCK_REDUCTION)
|
|
453
|
+
blocked = true
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
finalDamage = math.max(math.floor(finalDamage), MIN_DAMAGE)
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
rawDamage = rawDamage,
|
|
460
|
+
finalDamage = finalDamage,
|
|
461
|
+
isCritical = isCritical,
|
|
462
|
+
damageType = weapon.damageType,
|
|
463
|
+
blocked = blocked,
|
|
464
|
+
}
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
return DamageCalculator
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Damage Numbers Display (Client)
|
|
471
|
+
|
|
472
|
+
```luau
|
|
473
|
+
-- DamageDisplay.luau (ModuleScript in ReplicatedStorage, called from client)
|
|
474
|
+
|
|
475
|
+
local TweenService = game:GetService("TweenService")
|
|
476
|
+
|
|
477
|
+
local DamageDisplay = {}
|
|
478
|
+
|
|
479
|
+
local COLORS = {
|
|
480
|
+
Normal = Color3.fromRGB(255, 255, 255),
|
|
481
|
+
Critical = Color3.fromRGB(255, 50, 50),
|
|
482
|
+
Blocked = Color3.fromRGB(150, 150, 150),
|
|
483
|
+
Heal = Color3.fromRGB(50, 255, 50),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function DamageDisplay.show(target: Model, amount: number, isCritical: boolean, blocked: boolean)
|
|
487
|
+
local head = target:FindFirstChild("Head") :: BasePart?
|
|
488
|
+
if not head then
|
|
489
|
+
return
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
local billboard = Instance.new("BillboardGui")
|
|
493
|
+
billboard.Size = UDim2.fromOffset(100, 40)
|
|
494
|
+
billboard.StudsOffset = Vector3.new(math.random(-2, 2), 2, 0)
|
|
495
|
+
billboard.AlwaysOnTop = true
|
|
496
|
+
billboard.Parent = head
|
|
497
|
+
|
|
498
|
+
local label = Instance.new("TextLabel")
|
|
499
|
+
label.Size = UDim2.fromScale(1, 1)
|
|
500
|
+
label.BackgroundTransparency = 1
|
|
501
|
+
label.Text = tostring(amount)
|
|
502
|
+
label.Font = Enum.Font.GothamBold
|
|
503
|
+
label.TextScaled = true
|
|
504
|
+
|
|
505
|
+
if blocked then
|
|
506
|
+
label.TextColor3 = COLORS.Blocked
|
|
507
|
+
label.Text = amount .. " (Blocked)"
|
|
508
|
+
elseif isCritical then
|
|
509
|
+
label.TextColor3 = COLORS.Critical
|
|
510
|
+
label.Text = amount .. "!"
|
|
511
|
+
label.TextSize = 28
|
|
512
|
+
else
|
|
513
|
+
label.TextColor3 = COLORS.Normal
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
label.Parent = billboard
|
|
517
|
+
|
|
518
|
+
-- Float up and fade
|
|
519
|
+
local tweenInfo = TweenInfo.new(1.0, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
520
|
+
local tween = TweenService:Create(billboard, tweenInfo, {
|
|
521
|
+
StudsOffset = billboard.StudsOffset + Vector3.new(0, 3, 0),
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
local fadeTween = TweenService:Create(label, tweenInfo, {
|
|
525
|
+
TextTransparency = 1,
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
tween:Play()
|
|
529
|
+
fadeTween:Play()
|
|
530
|
+
|
|
531
|
+
fadeTween.Completed:Once(function()
|
|
532
|
+
billboard:Destroy()
|
|
533
|
+
end)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
return DamageDisplay
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## 6. Melee Combat
|
|
542
|
+
|
|
543
|
+
### Swing Detection
|
|
544
|
+
|
|
545
|
+
The server creates a hitbox in front of the attacker for the duration of the attack state. Only characters that intersect the box during the active window take damage.
|
|
546
|
+
|
|
547
|
+
```luau
|
|
548
|
+
local function performMeleeAttack(attacker: Model, weapon: WeaponStats, comboHit: number): {DamageResult}
|
|
549
|
+
local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
550
|
+
if not rootPart then
|
|
551
|
+
return {}
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
local targets = getHitTargets(rootPart, weapon.range or 7, weapon.width or 5, weapon.height or 6)
|
|
555
|
+
local results: {DamageResult} = {}
|
|
556
|
+
|
|
557
|
+
for _, targetModel in targets do
|
|
558
|
+
local humanoid = targetModel:FindFirstChildWhichIsA("Humanoid")
|
|
559
|
+
if not humanoid or humanoid.Health <= 0 then
|
|
560
|
+
continue
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
local defenderStats = getStatsForCharacter(targetModel) -- your stats lookup
|
|
564
|
+
local isBlocking = getStateMachine(targetModel):getState() == "Blocking"
|
|
565
|
+
|
|
566
|
+
local result = DamageCalculator.calculate(weapon, getStatsForCharacter(attacker), defenderStats, comboHit, isBlocking)
|
|
567
|
+
humanoid:TakeDamage(result.finalDamage)
|
|
568
|
+
table.insert(results, result)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
return results
|
|
572
|
+
end
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Combo System
|
|
576
|
+
|
|
577
|
+
Track consecutive hits within the combo window. Each successive hit escalates damage:
|
|
578
|
+
|
|
579
|
+
| Combo Hit | Multiplier | Typical Effect |
|
|
580
|
+
|---|---|---|
|
|
581
|
+
| 1 | 1.0x | Normal swing |
|
|
582
|
+
| 2 | 1.1x | Faster animation |
|
|
583
|
+
| 3 | 1.25x | Wider hitbox |
|
|
584
|
+
| 4 | 1.5x | Finisher with knockback |
|
|
585
|
+
|
|
586
|
+
The state machine tracks `comboCount`. When the combo window expires (player doesn't attack within `COMBO_WINDOW` seconds), the count resets to 0 on transition back to Idle.
|
|
587
|
+
|
|
588
|
+
### Parry / Block
|
|
589
|
+
|
|
590
|
+
- **Block**: Hold a button to enter Blocking state. Incoming damage is reduced by `BLOCK_REDUCTION` (80%). Blocking drains a stamina resource (optional). If stamina hits 0, the block breaks and the player is Stunned.
|
|
591
|
+
- **Parry**: A precisely timed block (within ~0.15s of an incoming attack) negates all damage and stuns the attacker instead. Implementation: check if the defender entered Blocking state within a `PARRY_WINDOW` before the hit lands.
|
|
592
|
+
|
|
593
|
+
```luau
|
|
594
|
+
local PARRY_WINDOW = 0.15
|
|
595
|
+
|
|
596
|
+
local function isParry(defenderStateMachine): boolean
|
|
597
|
+
if defenderStateMachine:getState() ~= "Blocking" then
|
|
598
|
+
return false
|
|
599
|
+
end
|
|
600
|
+
local blockDuration = os.clock() - defenderStateMachine.context.stateStartTime
|
|
601
|
+
return blockDuration <= PARRY_WINDOW
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Knockback
|
|
606
|
+
|
|
607
|
+
Apply knockback on combo finishers or heavy attacks using `LinearVelocity` (preferred over deprecated `BodyVelocity`):
|
|
608
|
+
|
|
609
|
+
```luau
|
|
610
|
+
local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number, duration: number)
|
|
611
|
+
local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
|
|
612
|
+
if not attachment then
|
|
613
|
+
return
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
local linearVelocity = Instance.new("LinearVelocity")
|
|
617
|
+
linearVelocity.Attachment0 = attachment
|
|
618
|
+
linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
|
|
619
|
+
linearVelocity.MaxForce = math.huge
|
|
620
|
+
linearVelocity.VectorVelocity = direction.Unit * force
|
|
621
|
+
linearVelocity.Parent = targetRootPart
|
|
622
|
+
|
|
623
|
+
task.delay(duration, function()
|
|
624
|
+
linearVelocity:Destroy()
|
|
625
|
+
end)
|
|
626
|
+
end
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## 7. Ranged Combat
|
|
632
|
+
|
|
633
|
+
### Projectile System
|
|
634
|
+
|
|
635
|
+
Create a physical part that moves each frame. Good for visible, dodgeable projectiles.
|
|
636
|
+
|
|
637
|
+
```luau
|
|
638
|
+
local RunService = game:GetService("RunService")
|
|
639
|
+
|
|
640
|
+
local function fireProjectile(origin: CFrame, speed: number, maxDistance: number, gravity: number, onHit: (RaycastResult) -> ())
|
|
641
|
+
local projectile = Instance.new("Part")
|
|
642
|
+
projectile.Size = Vector3.new(0.3, 0.3, 1)
|
|
643
|
+
projectile.CFrame = origin
|
|
644
|
+
projectile.Anchored = true
|
|
645
|
+
projectile.CanCollide = false
|
|
646
|
+
projectile.Parent = workspace
|
|
647
|
+
|
|
648
|
+
local velocity = origin.LookVector * speed
|
|
649
|
+
local distanceTraveled = 0
|
|
650
|
+
|
|
651
|
+
local raycastParams = RaycastParams.new()
|
|
652
|
+
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
653
|
+
raycastParams.FilterDescendantsInstances = { projectile }
|
|
654
|
+
|
|
655
|
+
local connection: RBXScriptConnection
|
|
656
|
+
connection = RunService.Heartbeat:Connect(function(dt: number)
|
|
657
|
+
-- Apply gravity
|
|
658
|
+
velocity += Vector3.new(0, -gravity * dt, 0)
|
|
659
|
+
|
|
660
|
+
local displacement = velocity * dt
|
|
661
|
+
local rayResult = workspace:Raycast(projectile.Position, displacement, raycastParams)
|
|
662
|
+
|
|
663
|
+
if rayResult then
|
|
664
|
+
connection:Disconnect()
|
|
665
|
+
onHit(rayResult)
|
|
666
|
+
projectile:Destroy()
|
|
667
|
+
return
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
projectile.CFrame = CFrame.new(projectile.Position + displacement, projectile.Position + displacement + velocity)
|
|
671
|
+
distanceTraveled += displacement.Magnitude
|
|
672
|
+
|
|
673
|
+
if distanceTraveled >= maxDistance then
|
|
674
|
+
connection:Disconnect()
|
|
675
|
+
projectile:Destroy()
|
|
676
|
+
end
|
|
677
|
+
end)
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### Hitscan (Instant Raycast)
|
|
682
|
+
|
|
683
|
+
For sniper rifles, laser beams, or any instant-hit weapon:
|
|
684
|
+
|
|
685
|
+
```luau
|
|
686
|
+
local function hitscanAttack(attacker: Model, aimDirection: Vector3, maxRange: number): RaycastResult?
|
|
687
|
+
local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
688
|
+
if not rootPart then
|
|
689
|
+
return nil
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
local origin = rootPart.Position + Vector3.new(0, 1.5, 0) -- eye height
|
|
693
|
+
local raycastParams = RaycastParams.new()
|
|
694
|
+
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
695
|
+
raycastParams.FilterDescendantsInstances = { attacker }
|
|
696
|
+
|
|
697
|
+
return workspace:Raycast(origin, aimDirection.Unit * maxRange, raycastParams)
|
|
698
|
+
end
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### Bullet Drop
|
|
702
|
+
|
|
703
|
+
Simulate gravity over distance by bending the ray. For longer ranges, split into segments:
|
|
704
|
+
|
|
705
|
+
```luau
|
|
706
|
+
local function raycastWithDrop(origin: Vector3, direction: Vector3, segments: number, dropPerSegment: number): RaycastResult?
|
|
707
|
+
local segmentLength = direction.Magnitude / segments
|
|
708
|
+
local currentPos = origin
|
|
709
|
+
local currentDir = direction.Unit
|
|
710
|
+
|
|
711
|
+
local raycastParams = RaycastParams.new()
|
|
712
|
+
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
713
|
+
|
|
714
|
+
for i = 1, segments do
|
|
715
|
+
local drop = Vector3.new(0, -dropPerSegment * i, 0)
|
|
716
|
+
local segmentDir = (currentDir * segmentLength) + drop
|
|
717
|
+
|
|
718
|
+
local result = workspace:Raycast(currentPos, segmentDir, raycastParams)
|
|
719
|
+
if result then
|
|
720
|
+
return result
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
currentPos += segmentDir
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
return nil
|
|
727
|
+
end
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Spread / Bloom Patterns
|
|
731
|
+
|
|
732
|
+
Add random deviation within a cone for shotguns, automatic weapons, or hip-fire:
|
|
733
|
+
|
|
734
|
+
```luau
|
|
735
|
+
local function applySpread(direction: Vector3, spreadAngleDegrees: number): Vector3
|
|
736
|
+
local spreadRadians = math.rad(spreadAngleDegrees)
|
|
737
|
+
local randomAngle = math.random() * math.pi * 2
|
|
738
|
+
local randomSpread = math.random() * spreadRadians
|
|
739
|
+
|
|
740
|
+
local right = direction:Cross(Vector3.yAxis).Unit
|
|
741
|
+
local up = right:Cross(direction).Unit
|
|
742
|
+
|
|
743
|
+
local offset = (right * math.cos(randomAngle) + up * math.sin(randomAngle)) * math.sin(randomSpread)
|
|
744
|
+
|
|
745
|
+
return (direction.Unit + offset).Unit
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
-- Usage: shotgun with 8 pellets, 12-degree spread
|
|
749
|
+
for i = 1, 8 do
|
|
750
|
+
local spreadDir = applySpread(aimDirection, 12)
|
|
751
|
+
local result = hitscanAttack(attacker, spreadDir, 50)
|
|
752
|
+
if result then
|
|
753
|
+
-- process hit
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
## 8. WCS (Weapon Combat System) Framework
|
|
761
|
+
|
|
762
|
+
### What It Is
|
|
763
|
+
|
|
764
|
+
WCS is a community-built framework for Roblox combat that provides:
|
|
765
|
+
|
|
766
|
+
- Skill/ability definition with built-in cooldowns
|
|
767
|
+
- Status effects (buffs, debuffs, DoTs)
|
|
768
|
+
- Moveset management (assign skills to characters)
|
|
769
|
+
- Client-server synchronization out of the box
|
|
770
|
+
- Holdable skills, channeling, and charge mechanics
|
|
771
|
+
|
|
772
|
+
### When to Use WCS
|
|
773
|
+
|
|
774
|
+
| Scenario | Recommendation |
|
|
775
|
+
|---|---|
|
|
776
|
+
| Complex skill-based combat (RPG, fighting game) | **Use WCS** -- saves weeks of boilerplate |
|
|
777
|
+
| Many unique abilities with varied behavior | **Use WCS** -- skill definition system is well designed |
|
|
778
|
+
| Simple melee/ranged with few weapons | **Build custom** -- WCS adds unnecessary overhead |
|
|
779
|
+
| Learning combat fundamentals | **Build custom** -- understand the internals first |
|
|
780
|
+
| Very custom combo/input systems | **Build custom or extend WCS** -- may fight the framework |
|
|
781
|
+
|
|
782
|
+
### Basic WCS Skill Definition
|
|
783
|
+
|
|
784
|
+
```luau
|
|
785
|
+
-- Example: a fireball skill in WCS
|
|
786
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
787
|
+
local WCS = require(ReplicatedStorage.Packages.WCS)
|
|
788
|
+
|
|
789
|
+
local Fireball = WCS.RegisterSkill("Fireball")
|
|
790
|
+
Fireball.CooldownTime = 3
|
|
791
|
+
Fireball.MaxHoldTime = 2
|
|
792
|
+
|
|
793
|
+
function Fireball:OnStartServer()
|
|
794
|
+
-- Server-side fireball logic
|
|
795
|
+
local character = self.Character
|
|
796
|
+
-- spawn projectile, deal damage, etc.
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
function Fireball:OnStartClient()
|
|
800
|
+
-- Client-side VFX
|
|
801
|
+
-- play casting animation, spawn particle emitter
|
|
802
|
+
end
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
Install WCS via Wally: `wcs = "digest/wcs@latest"` or grab the model from the toolbox.
|
|
806
|
+
|
|
807
|
+
---
|
|
808
|
+
|
|
809
|
+
## 9. Cooldown Systems
|
|
810
|
+
|
|
811
|
+
### Server-Side Cooldown Tracking
|
|
812
|
+
|
|
813
|
+
All cooldowns must be tracked on the server. The client can display a timer, but the server rejects actions that violate cooldowns.
|
|
814
|
+
|
|
815
|
+
```luau
|
|
816
|
+
--!strict
|
|
817
|
+
-- CooldownManager.luau (ModuleScript in ServerScriptService)
|
|
818
|
+
|
|
819
|
+
local CooldownManager = {}
|
|
820
|
+
CooldownManager.__index = CooldownManager
|
|
821
|
+
|
|
822
|
+
type CooldownMap = {[string]: number} -- abilityName -> expiry timestamp
|
|
823
|
+
type PlayerCooldowns = {[Player]: CooldownMap}
|
|
824
|
+
|
|
825
|
+
local cooldowns: PlayerCooldowns = {}
|
|
826
|
+
|
|
827
|
+
function CooldownManager.initialize(player: Player)
|
|
828
|
+
cooldowns[player] = {}
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
function CooldownManager.cleanup(player: Player)
|
|
832
|
+
cooldowns[player] = nil
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
function CooldownManager.isReady(player: Player, abilityName: string): boolean
|
|
836
|
+
local playerCooldowns = cooldowns[player]
|
|
837
|
+
if not playerCooldowns then
|
|
838
|
+
return false
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
local expiry = playerCooldowns[abilityName]
|
|
842
|
+
if not expiry then
|
|
843
|
+
return true
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
return os.clock() >= expiry
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
function CooldownManager.startCooldown(player: Player, abilityName: string, duration: number)
|
|
850
|
+
local playerCooldowns = cooldowns[player]
|
|
851
|
+
if not playerCooldowns then
|
|
852
|
+
return
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
playerCooldowns[abilityName] = os.clock() + duration
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
function CooldownManager.getRemainingTime(player: Player, abilityName: string): number
|
|
859
|
+
local playerCooldowns = cooldowns[player]
|
|
860
|
+
if not playerCooldowns then
|
|
861
|
+
return 0
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
local expiry = playerCooldowns[abilityName]
|
|
865
|
+
if not expiry then
|
|
866
|
+
return 0
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
return math.max(0, expiry - os.clock())
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
return CooldownManager
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### Client-Side Cooldown Display
|
|
876
|
+
|
|
877
|
+
```luau
|
|
878
|
+
-- CooldownUI.luau (LocalScript in StarterPlayerScripts)
|
|
879
|
+
|
|
880
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
881
|
+
local RunService = game:GetService("RunService")
|
|
882
|
+
local cooldownEvent = ReplicatedStorage:WaitForChild("CooldownStarted") :: RemoteEvent
|
|
883
|
+
|
|
884
|
+
local activeCooldowns: {[string]: {startTime: number, duration: number, frame: Frame}} = {}
|
|
885
|
+
|
|
886
|
+
cooldownEvent.OnClientEvent:Connect(function(abilityName: string, duration: number)
|
|
887
|
+
local frame = findAbilityFrame(abilityName) -- your UI lookup
|
|
888
|
+
if not frame then
|
|
889
|
+
return
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
local overlay = frame:FindFirstChild("CooldownOverlay") :: Frame
|
|
893
|
+
local label = frame:FindFirstChild("CooldownLabel") :: TextLabel
|
|
894
|
+
|
|
895
|
+
activeCooldowns[abilityName] = {
|
|
896
|
+
startTime = os.clock(),
|
|
897
|
+
duration = duration,
|
|
898
|
+
frame = frame,
|
|
899
|
+
}
|
|
900
|
+
end)
|
|
901
|
+
|
|
902
|
+
RunService.RenderStepped:Connect(function()
|
|
903
|
+
for abilityName, data in activeCooldowns do
|
|
904
|
+
local elapsed = os.clock() - data.startTime
|
|
905
|
+
local remaining = data.duration - elapsed
|
|
906
|
+
|
|
907
|
+
if remaining <= 0 then
|
|
908
|
+
-- Cooldown finished
|
|
909
|
+
local overlay = data.frame:FindFirstChild("CooldownOverlay") :: Frame?
|
|
910
|
+
if overlay then
|
|
911
|
+
overlay.Size = UDim2.fromScale(1, 0)
|
|
912
|
+
end
|
|
913
|
+
local label = data.frame:FindFirstChild("CooldownLabel") :: TextLabel?
|
|
914
|
+
if label then
|
|
915
|
+
label.Text = ""
|
|
916
|
+
end
|
|
917
|
+
activeCooldowns[abilityName] = nil
|
|
918
|
+
continue
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
-- Update sweep and text
|
|
922
|
+
local fraction = remaining / data.duration
|
|
923
|
+
local overlay = data.frame:FindFirstChild("CooldownOverlay") :: Frame?
|
|
924
|
+
if overlay then
|
|
925
|
+
overlay.Size = UDim2.fromScale(1, fraction)
|
|
926
|
+
end
|
|
927
|
+
local label = data.frame:FindFirstChild("CooldownLabel") :: TextLabel?
|
|
928
|
+
if label then
|
|
929
|
+
label.Text = string.format("%.1f", remaining)
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
end)
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
## 10. Complete Melee Combat System
|
|
938
|
+
|
|
939
|
+
This ties together the state machine, hitbox detection, damage calculation, and cooldowns into a working server-side combat handler.
|
|
940
|
+
|
|
941
|
+
```luau
|
|
942
|
+
--!strict
|
|
943
|
+
-- MeleeCombatServer.luau (Script in ServerScriptService)
|
|
944
|
+
|
|
945
|
+
local Players = game:GetService("Players")
|
|
946
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
947
|
+
local RunService = game:GetService("RunService")
|
|
948
|
+
|
|
949
|
+
local CombatStateMachine = require(ReplicatedStorage:WaitForChild("CombatStateMachine"))
|
|
950
|
+
local DamageCalculator = require(game.ServerScriptService:WaitForChild("DamageCalculator"))
|
|
951
|
+
local CooldownManager = require(game.ServerScriptService:WaitForChild("CooldownManager"))
|
|
952
|
+
|
|
953
|
+
-- RemoteEvents
|
|
954
|
+
local attackRemote = ReplicatedStorage:WaitForChild("AttackRemote") :: RemoteEvent
|
|
955
|
+
local blockRemote = ReplicatedStorage:WaitForChild("BlockRemote") :: RemoteEvent
|
|
956
|
+
local dodgeRemote = ReplicatedStorage:WaitForChild("DodgeRemote") :: RemoteEvent
|
|
957
|
+
local combatResultRemote = ReplicatedStorage:WaitForChild("CombatResultRemote") :: RemoteEvent
|
|
958
|
+
local cooldownRemote = ReplicatedStorage:WaitForChild("CooldownStarted") :: RemoteEvent
|
|
959
|
+
|
|
960
|
+
-- Per-player state
|
|
961
|
+
local playerStateMachines: {[Player]: typeof(CombatStateMachine.new(nil :: any))} = {}
|
|
962
|
+
|
|
963
|
+
-- Weapon config (in production, load from a data module)
|
|
964
|
+
local DEFAULT_WEAPON: DamageCalculator.WeaponStats = {
|
|
965
|
+
baseDamage = 20,
|
|
966
|
+
weaponMultiplier = 1.0,
|
|
967
|
+
damageType = "Physical",
|
|
968
|
+
critChance = 0.1,
|
|
969
|
+
critMultiplier = 1.5,
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
local DEFAULT_STATS: DamageCalculator.CombatStats = {
|
|
973
|
+
attackBuff = 0,
|
|
974
|
+
defense = 0,
|
|
975
|
+
resistances = {},
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
local ATTACK_COOLDOWN = 0.3
|
|
979
|
+
local DODGE_INVULN_TAG = "DodgeInvuln"
|
|
980
|
+
|
|
981
|
+
-- Hitbox detection
|
|
982
|
+
local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model}
|
|
983
|
+
local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2)
|
|
984
|
+
local size = Vector3.new(width, height, range)
|
|
985
|
+
|
|
986
|
+
local overlapParams = OverlapParams.new()
|
|
987
|
+
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
|
|
988
|
+
overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent }
|
|
989
|
+
|
|
990
|
+
local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams)
|
|
991
|
+
|
|
992
|
+
local hitCharacters: {[Model]: true} = {}
|
|
993
|
+
local results: {Model} = {}
|
|
994
|
+
|
|
995
|
+
for _, part in parts do
|
|
996
|
+
local model = part:FindFirstAncestorWhichIsA("Model")
|
|
997
|
+
if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then
|
|
998
|
+
hitCharacters[model] = true
|
|
999
|
+
table.insert(results, model)
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
return results
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
-- Knockback helper
|
|
1007
|
+
local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number)
|
|
1008
|
+
local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
|
|
1009
|
+
if not attachment then
|
|
1010
|
+
return
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
local linearVelocity = Instance.new("LinearVelocity")
|
|
1014
|
+
linearVelocity.Attachment0 = attachment
|
|
1015
|
+
linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
|
|
1016
|
+
linearVelocity.MaxForce = math.huge
|
|
1017
|
+
linearVelocity.VectorVelocity = direction.Unit * force
|
|
1018
|
+
linearVelocity.Parent = targetRootPart
|
|
1019
|
+
|
|
1020
|
+
task.delay(0.3, function()
|
|
1021
|
+
linearVelocity:Destroy()
|
|
1022
|
+
end)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
-- Parry check
|
|
1026
|
+
local PARRY_WINDOW = 0.15
|
|
1027
|
+
|
|
1028
|
+
local function isParry(defenderSM: typeof(CombatStateMachine.new(nil :: any))): boolean
|
|
1029
|
+
if defenderSM:getState() ~= "Blocking" then
|
|
1030
|
+
return false
|
|
1031
|
+
end
|
|
1032
|
+
return (os.clock() - defenderSM.context.stateStartTime) <= PARRY_WINDOW
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
-- Get state machine for a character model (NPC or player)
|
|
1036
|
+
local function getStateMachineForCharacter(character: Model): typeof(CombatStateMachine.new(nil :: any))?
|
|
1037
|
+
local player = Players:GetPlayerFromCharacter(character)
|
|
1038
|
+
if player then
|
|
1039
|
+
return playerStateMachines[player]
|
|
1040
|
+
end
|
|
1041
|
+
return nil
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
-- Handle attack
|
|
1045
|
+
local function onAttack(player: Player)
|
|
1046
|
+
local sm = playerStateMachines[player]
|
|
1047
|
+
if not sm then
|
|
1048
|
+
return
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
-- Cooldown check
|
|
1052
|
+
if not CooldownManager.isReady(player, "Attack") then
|
|
1053
|
+
return
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
-- State machine check
|
|
1057
|
+
if not sm:tryAttack() then
|
|
1058
|
+
return
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
CooldownManager.startCooldown(player, "Attack", ATTACK_COOLDOWN)
|
|
1062
|
+
|
|
1063
|
+
-- Get character
|
|
1064
|
+
local character = player.Character
|
|
1065
|
+
if not character then
|
|
1066
|
+
return
|
|
1067
|
+
end
|
|
1068
|
+
local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
|
|
1069
|
+
if not rootPart then
|
|
1070
|
+
return
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
-- Hitbox detection
|
|
1074
|
+
local comboHit = sm:getComboCount()
|
|
1075
|
+
local range = if comboHit >= 4 then 9 else 7
|
|
1076
|
+
local width = if comboHit >= 3 then 7 else 5
|
|
1077
|
+
local targets = getHitTargets(rootPart, range, width, 6)
|
|
1078
|
+
|
|
1079
|
+
for _, targetModel in targets do
|
|
1080
|
+
local targetHumanoid = targetModel:FindFirstChildWhichIsA("Humanoid")
|
|
1081
|
+
if not targetHumanoid or targetHumanoid.Health <= 0 then
|
|
1082
|
+
continue
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
-- Check dodge invulnerability
|
|
1086
|
+
if targetModel:FindFirstChild(DODGE_INVULN_TAG) then
|
|
1087
|
+
continue
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
-- Check parry
|
|
1091
|
+
local targetSM = getStateMachineForCharacter(targetModel)
|
|
1092
|
+
if targetSM and isParry(targetSM) then
|
|
1093
|
+
-- Parry: stun the attacker instead
|
|
1094
|
+
sm:applyStun(1.2)
|
|
1095
|
+
combatResultRemote:FireClient(player, "Parried", targetModel)
|
|
1096
|
+
|
|
1097
|
+
local targetPlayer = Players:GetPlayerFromCharacter(targetModel)
|
|
1098
|
+
if targetPlayer then
|
|
1099
|
+
combatResultRemote:FireClient(targetPlayer, "ParrySuccess", character)
|
|
1100
|
+
end
|
|
1101
|
+
continue
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
-- Calculate damage
|
|
1105
|
+
local isBlocking = if targetSM then targetSM:getState() == "Blocking" else false
|
|
1106
|
+
local attackerStats = DEFAULT_STATS -- replace with real stats lookup
|
|
1107
|
+
local defenderStats = DEFAULT_STATS -- replace with real stats lookup
|
|
1108
|
+
|
|
1109
|
+
local result = DamageCalculator.calculate(DEFAULT_WEAPON, attackerStats, defenderStats, comboHit, isBlocking)
|
|
1110
|
+
targetHumanoid:TakeDamage(result.finalDamage)
|
|
1111
|
+
|
|
1112
|
+
-- Knockback on combo finisher
|
|
1113
|
+
local targetRootPart = targetModel:FindFirstChild("HumanoidRootPart") :: BasePart?
|
|
1114
|
+
if comboHit >= 4 and targetRootPart then
|
|
1115
|
+
local knockDir = (targetRootPart.Position - rootPart.Position)
|
|
1116
|
+
applyKnockback(targetRootPart, knockDir, 50)
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
-- Notify all relevant clients
|
|
1120
|
+
combatResultRemote:FireAllClients("DamageDealt", {
|
|
1121
|
+
target = targetModel,
|
|
1122
|
+
amount = result.finalDamage,
|
|
1123
|
+
isCritical = result.isCritical,
|
|
1124
|
+
blocked = result.blocked,
|
|
1125
|
+
comboHit = comboHit,
|
|
1126
|
+
})
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
-- Handle block
|
|
1131
|
+
local function onBlock(player: Player, isBlocking: boolean)
|
|
1132
|
+
local sm = playerStateMachines[player]
|
|
1133
|
+
if not sm then
|
|
1134
|
+
return
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
if isBlocking then
|
|
1138
|
+
sm:tryBlock()
|
|
1139
|
+
else
|
|
1140
|
+
sm:releaseBlock()
|
|
1141
|
+
end
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
-- Handle dodge
|
|
1145
|
+
local function onDodge(player: Player)
|
|
1146
|
+
local sm = playerStateMachines[player]
|
|
1147
|
+
if not sm then
|
|
1148
|
+
return
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
if not sm:tryDodge() then
|
|
1152
|
+
return
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
local character = player.Character
|
|
1156
|
+
if not character then
|
|
1157
|
+
return
|
|
1158
|
+
end
|
|
1159
|
+
local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
|
|
1160
|
+
if not rootPart then
|
|
1161
|
+
return
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
-- Add invulnerability tag
|
|
1165
|
+
local tag = Instance.new("BoolValue")
|
|
1166
|
+
tag.Name = DODGE_INVULN_TAG
|
|
1167
|
+
tag.Parent = character
|
|
1168
|
+
|
|
1169
|
+
-- Apply dodge movement
|
|
1170
|
+
local moveDir = rootPart.CFrame.LookVector
|
|
1171
|
+
applyKnockback(rootPart, moveDir, 60)
|
|
1172
|
+
|
|
1173
|
+
-- Notify client for cooldown display
|
|
1174
|
+
cooldownRemote:FireClient(player, "Dodge", 1.5)
|
|
1175
|
+
|
|
1176
|
+
-- Remove tag after dodge duration
|
|
1177
|
+
task.delay(0.5, function()
|
|
1178
|
+
tag:Destroy()
|
|
1179
|
+
end)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
-- Player setup / teardown
|
|
1183
|
+
Players.PlayerAdded:Connect(function(player: Player)
|
|
1184
|
+
CooldownManager.initialize(player)
|
|
1185
|
+
|
|
1186
|
+
player.CharacterAdded:Connect(function()
|
|
1187
|
+
local sm = CombatStateMachine.new(player)
|
|
1188
|
+
playerStateMachines[player] = sm
|
|
1189
|
+
end)
|
|
1190
|
+
|
|
1191
|
+
player.CharacterRemoving:Connect(function()
|
|
1192
|
+
local sm = playerStateMachines[player]
|
|
1193
|
+
if sm then
|
|
1194
|
+
sm:destroy()
|
|
1195
|
+
playerStateMachines[player] = nil
|
|
1196
|
+
end
|
|
1197
|
+
end)
|
|
1198
|
+
end)
|
|
1199
|
+
|
|
1200
|
+
Players.PlayerRemoving:Connect(function(player: Player)
|
|
1201
|
+
CooldownManager.cleanup(player)
|
|
1202
|
+
local sm = playerStateMachines[player]
|
|
1203
|
+
if sm then
|
|
1204
|
+
sm:destroy()
|
|
1205
|
+
playerStateMachines[player] = nil
|
|
1206
|
+
end
|
|
1207
|
+
end)
|
|
1208
|
+
|
|
1209
|
+
-- Update state machines every frame
|
|
1210
|
+
RunService.Heartbeat:Connect(function()
|
|
1211
|
+
for _, sm in playerStateMachines do
|
|
1212
|
+
sm:update()
|
|
1213
|
+
end
|
|
1214
|
+
end)
|
|
1215
|
+
|
|
1216
|
+
-- Connect remotes
|
|
1217
|
+
attackRemote.OnServerEvent:Connect(onAttack)
|
|
1218
|
+
blockRemote.OnServerEvent:Connect(function(player: Player, isBlocking: boolean)
|
|
1219
|
+
if typeof(isBlocking) ~= "boolean" then
|
|
1220
|
+
return -- type validation
|
|
1221
|
+
end
|
|
1222
|
+
onBlock(player, isBlocking)
|
|
1223
|
+
end)
|
|
1224
|
+
dodgeRemote.OnServerEvent:Connect(onDodge)
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
### Client-Side Input Handler
|
|
1228
|
+
|
|
1229
|
+
```luau
|
|
1230
|
+
--!strict
|
|
1231
|
+
-- CombatInput.luau (LocalScript in StarterPlayerScripts)
|
|
1232
|
+
|
|
1233
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
1234
|
+
local UserInputService = game:GetService("UserInputService")
|
|
1235
|
+
|
|
1236
|
+
local attackRemote = ReplicatedStorage:WaitForChild("AttackRemote") :: RemoteEvent
|
|
1237
|
+
local blockRemote = ReplicatedStorage:WaitForChild("BlockRemote") :: RemoteEvent
|
|
1238
|
+
local dodgeRemote = ReplicatedStorage:WaitForChild("DodgeRemote") :: RemoteEvent
|
|
1239
|
+
|
|
1240
|
+
-- Attack: left mouse click
|
|
1241
|
+
UserInputService.InputBegan:Connect(function(input: InputObject, gameProcessed: boolean)
|
|
1242
|
+
if gameProcessed then
|
|
1243
|
+
return
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
if input.UserInputType == Enum.UserInputType.MouseButton1 then
|
|
1247
|
+
attackRemote:FireServer()
|
|
1248
|
+
elseif input.KeyCode == Enum.KeyCode.F then
|
|
1249
|
+
blockRemote:FireServer(true)
|
|
1250
|
+
elseif input.KeyCode == Enum.KeyCode.Q then
|
|
1251
|
+
dodgeRemote:FireServer()
|
|
1252
|
+
end
|
|
1253
|
+
end)
|
|
1254
|
+
|
|
1255
|
+
-- Release block
|
|
1256
|
+
UserInputService.InputEnded:Connect(function(input: InputObject, gameProcessed: boolean)
|
|
1257
|
+
if input.KeyCode == Enum.KeyCode.F then
|
|
1258
|
+
blockRemote:FireServer(false)
|
|
1259
|
+
end
|
|
1260
|
+
end)
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
---
|
|
1264
|
+
|
|
1265
|
+
## 11. Best Practices
|
|
1266
|
+
|
|
1267
|
+
### Server Authority
|
|
1268
|
+
|
|
1269
|
+
- **All damage is applied server-side.** The client never calls `Humanoid:TakeDamage()`.
|
|
1270
|
+
- **Validate every RemoteEvent argument.** Check `typeof()`, clamp numeric ranges, verify the player owns the weapon they claim to use.
|
|
1271
|
+
- **Run hitbox detection on the server** using the server's known character positions, not client-reported positions.
|
|
1272
|
+
|
|
1273
|
+
### Hit Registration Integrity
|
|
1274
|
+
|
|
1275
|
+
- Use `OverlapParams.FilterDescendantsInstances` to exclude the attacker's own character from hitbox results.
|
|
1276
|
+
- Deduplicate hits: track which characters were already hit during a single attack swing to prevent multi-hit exploits.
|
|
1277
|
+
- Limit hit targets per swing (e.g., max 5) to prevent AoE exploits.
|
|
1278
|
+
|
|
1279
|
+
### Fair PvP
|
|
1280
|
+
|
|
1281
|
+
- Normalize base stats in PvP arenas so gear differences don't create unfair advantages, or use matchmaking tiers.
|
|
1282
|
+
- Telegraph attacks: give enemies visual/audio cues (wind-up animation, sound effect) before damage lands so they can react.
|
|
1283
|
+
- Balance risk vs. reward: powerful attacks should have longer recovery, wider parry windows, or more telegraphing.
|
|
1284
|
+
|
|
1285
|
+
### Performance
|
|
1286
|
+
|
|
1287
|
+
- Don't create new `OverlapParams` / `RaycastParams` every frame. Create once, reuse, update the filter list as needed.
|
|
1288
|
+
- Pool damage number BillboardGuis instead of creating/destroying them constantly.
|
|
1289
|
+
- For large-scale battles (20+ players), consider spatial partitioning to limit hitbox checks to nearby characters only.
|
|
1290
|
+
|
|
1291
|
+
---
|
|
1292
|
+
|
|
1293
|
+
## 12. Anti-Patterns
|
|
1294
|
+
|
|
1295
|
+
### Client-Side Damage Calculation
|
|
1296
|
+
|
|
1297
|
+
```luau
|
|
1298
|
+
-- BAD: Client decides damage and tells server
|
|
1299
|
+
attackRemote:FireServer(targetPlayer, 9999) -- exploiter sends any number
|
|
1300
|
+
|
|
1301
|
+
-- GOOD: Client sends intent, server calculates
|
|
1302
|
+
attackRemote:FireServer() -- server determines targets and damage
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
### Trusting Client Hitbox Results
|
|
1306
|
+
|
|
1307
|
+
```luau
|
|
1308
|
+
-- BAD: Client reports who it hit
|
|
1309
|
+
attackRemote:FireServer(hitTargets) -- exploiter sends all players on server
|
|
1310
|
+
|
|
1311
|
+
-- GOOD: Server runs its own hitbox detection
|
|
1312
|
+
-- Client sends nothing except "I attacked"
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
### No Cooldown Enforcement
|
|
1316
|
+
|
|
1317
|
+
```luau
|
|
1318
|
+
-- BAD: Only client checks cooldowns (exploiter removes the check)
|
|
1319
|
+
-- BAD: Server tracks cooldown but doesn't reject early attacks
|
|
1320
|
+
|
|
1321
|
+
-- GOOD: Server rejects and silently drops requests that violate cooldowns
|
|
1322
|
+
if not CooldownManager.isReady(player, "Attack") then
|
|
1323
|
+
return -- silently ignore, don't send error messages exploiters can use
|
|
1324
|
+
end
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
### No State Validation
|
|
1328
|
+
|
|
1329
|
+
```luau
|
|
1330
|
+
-- BAD: Allow attacking while stunned, dead, or in menus
|
|
1331
|
+
-- BAD: Allow blocking and attacking simultaneously
|
|
1332
|
+
|
|
1333
|
+
-- GOOD: State machine enforces valid transitions
|
|
1334
|
+
if not stateMachine:canTransition("Attacking") then
|
|
1335
|
+
return
|
|
1336
|
+
end
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
### Instant Unavoidable Attacks
|
|
1340
|
+
|
|
1341
|
+
- Every attack should have a counter: dodging, blocking, parrying, or spacing.
|
|
1342
|
+
- If an attack is instant, it should deal low damage. If it deals high damage, it should be telegraphed and avoidable.
|
|
1343
|
+
- Avoid homing projectiles that are both fast and undodgeable.
|
|
1344
|
+
|
|
1345
|
+
### Floating Point Stat Stacking
|
|
1346
|
+
|
|
1347
|
+
```luau
|
|
1348
|
+
-- BAD: uncapped buff stacking
|
|
1349
|
+
totalBuff = totalBuff + newBuff -- can exceed 100%, goes to infinity
|
|
1350
|
+
|
|
1351
|
+
-- GOOD: clamp all multipliers
|
|
1352
|
+
totalBuff = math.clamp(totalBuff + newBuff, 0, 1.0) -- cap at 100% buff
|
|
1353
|
+
defense = math.clamp(defense, 0, 0.9) -- cap at 90% reduction, minimum damage always gets through
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
## Quick Reference: Required RemoteEvents
|
|
1359
|
+
|
|
1360
|
+
Create these in ReplicatedStorage for the complete system:
|
|
1361
|
+
|
|
1362
|
+
| RemoteEvent Name | Direction | Purpose |
|
|
1363
|
+
|---|---|---|
|
|
1364
|
+
| `AttackRemote` | Client -> Server | Player pressed attack |
|
|
1365
|
+
| `BlockRemote` | Client -> Server | Player started/stopped blocking |
|
|
1366
|
+
| `DodgeRemote` | Client -> Server | Player pressed dodge |
|
|
1367
|
+
| `CombatResultRemote` | Server -> Client | Damage dealt, parry, etc. for VFX |
|
|
1368
|
+
| `CooldownStarted` | Server -> Client | Ability cooldown began (for UI) |
|
|
1369
|
+
|
|
1370
|
+
---
|
|
1371
|
+
|
|
1372
|
+
## Quick Reference: Module Locations
|
|
1373
|
+
|
|
1374
|
+
| Module | Location | Purpose |
|
|
1375
|
+
|---|---|---|
|
|
1376
|
+
| `CombatStateMachine` | ReplicatedStorage | State machine (shared types) |
|
|
1377
|
+
| `DamageCalculator` | ServerScriptService | Damage formula (server only) |
|
|
1378
|
+
| `CooldownManager` | ServerScriptService | Cooldown tracking (server only) |
|
|
1379
|
+
| `DamageDisplay` | ReplicatedStorage | Floating damage numbers (client) |
|
|
1380
|
+
| `MeleeCombatServer` | ServerScriptService | Main combat handler (server) |
|
|
1381
|
+
| `CombatInput` | StarterPlayerScripts | Input -> RemoteEvent (client) |
|