Roblox Studioで剣の当たり判定を作る:serverでダメージを検証する方法
serverが攻撃間隔、範囲、向き、壁を検証し、TrainingDummyへ1振り1回だけ25ダメージを与える。

前の記事で作った振りへ、HP 100のTrainingDummyを75にする当たり判定を追加する。clientは「攻撃したい」とだけ送る。誰へ何damageを与えるかはserverが決める。

1. 最小テストmapとRemoteEventを用意する
最初に、同じ距離で正常命中と壁越しを比べられる小さなtest mapを作る。Workspaceへ次の4つを用意しよう。すでに床やSpawnLocationがある場合は、名前とPropertiesを次へ合わせればよい。
ArenaFloor—Part。Position = 0, 0, 0、Size = 82, 1, 56、Anchored = trueCombatSpawn—SpawnLocation。Position = 0, 1.5, 14、Size = 7, 0.4, 7、Anchored = true、Neutral = trueTrainingDummy > HumanoidRootPart—Position = 0, 3, 7、Anchored = trueTestWall—Part。Position = 0, 3.5, 10.5、Size = 5, 6, 1、Anchored = true、CanCollide = true、CanQuery = true
ArenaFloorとTestWallは上部のHome > Partから追加し、Explorerで名前を変える。CombatSpawnはWorkspace横の+からSpawnLocationを追加する。SpawnLocationはPlay開始位置を決めるObjectだ。壁は見分けやすいようにMaterial = Brick、床と違う色へ変えておこう。
Tip — PositionはX、Y、Zの順
Xは左右、Yは高さ、Zは前後だ。このmapではSpawnのZ = 14とDummyのZ = 7の中間へ、壁をZ = 10.5で置く。上から見ると3つが一直線になる。

通常命中を試すときはTestWall.PositionのXだけを7へ変え、壁を横へ退避する。壁越しを試すときはX = 0へ戻す。Z = 10.5は変えない。Play開始後にキャラクターを中央へ寄せ、Dummyの正面を向けば、targetまで約7 studsの同じ条件で2ケースを比べられる。
Avatar > Character > Generate RigでR15のblock rigを追加し、TrainingDummyへ変える。HumanoidのMaxHealthとHealthを100、HumanoidRootPartのAnchoredをオンにし、プレイヤーの正面へ置こう。この入口はRoblox公式のRig Generatorで案内されているCharacterボタンだ。
ReplicatedStorageへRemoteEventを追加し、SwordAttackと名付ける。これはclientからserverへ攻撃要求を渡す1本の通路だ。

Tip — RemoteEventは結果ではなく要求を送る
clientから
対象や25を渡すと、改造されたclientに偽の値を送られる。今回は引数なしのFireServer()だけにし、damage、範囲、cooldownをすべてserver側の定数にする。
前の記事のSwordAnimationClientで、tool.Activatedの中へ次の1行を追加する。
local attackEvent = game:GetService("ReplicatedStorage"):WaitForChild("SwordAttack")
-- busyをtrueにした直後
attackEvent:FireServer()
2. serverで攻撃の時間を所有する
ServerScriptServiceへScriptを追加してSwordCombatServerと名付ける。次のcodeを入れよう。
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local attackEvent = ReplicatedStorage:WaitForChild("SwordAttack")
local COOLDOWN = 0.95
local WINDUP = 0.28
local ACTIVE_DURATION = 0.12
local DAMAGE = 25
local HITBOX_SIZE = Vector3.new(6, 5, 7)
local MAX_DISTANCE = 8.5
local MIN_FACING_DOT = 0.2
local lastAttackAt = {}
local function visible(attacker, origin, target, targetPosition)
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = {attacker}
local hit = workspace:Raycast(origin, targetPosition - origin, params)
return hit == nil or hit.Instance:IsDescendantOf(target)
end
local function candidates(character)
local root = character:FindFirstChild("HumanoidRootPart")
if not root then return {} end
local params = OverlapParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = {character}
local box = root.CFrame * CFrame.new(0, 1.5, -4)
local parts = workspace:GetPartBoundsInBox(box, HITBOX_SIZE, params)
local result, seen = {}, {}
for _, part in parts do
local model = part:FindFirstAncestorOfClass("Model")
local humanoid = model and model:FindFirstChildOfClass("Humanoid")
if humanoid and humanoid.Health > 0 and not seen[humanoid] then
seen[humanoid] = true
table.insert(result, humanoid)
end
end
return result
end
local function validTarget(character, targetHumanoid)
local root = character:FindFirstChild("HumanoidRootPart")
local target = targetHumanoid.Parent
local targetRoot = target and target:FindFirstChild("HumanoidRootPart")
if not root or not targetRoot then return false end
local offset = targetRoot.Position - root.Position
if offset.Magnitude == 0 or offset.Magnitude > MAX_DISTANCE then
return false
end
if root.CFrame.LookVector:Dot(offset.Unit) < MIN_FACING_DOT then
return false
end
return visible(character, root.Position + Vector3.new(0, 1.8, 0), target, targetRoot.Position)
end
local function runAttack(player)
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
local tool = character and character:FindFirstChild("OriginalSword")
if not character or not humanoid or humanoid.Health <= 0 or not tool then
return
end
local now = os.clock()
if now - (lastAttackAt[player] or -math.huge) < COOLDOWN then
return
end
lastAttackAt[player] = now
player:SetAttribute("CombatPhase", "WINDUP")
task.wait(WINDUP)
if player.Character ~= character or humanoid.Health <= 0 then return end
player:SetAttribute("CombatPhase", "ACTIVE")
local hitThisSwing = {}
local startedAt = os.clock()
repeat
for _, targetHumanoid in candidates(character) do
if not hitThisSwing[targetHumanoid]
and validTarget(character, targetHumanoid) then
hitThisSwing[targetHumanoid] = true
targetHumanoid:TakeDamage(DAMAGE)
end
end
task.wait()
until os.clock() - startedAt >= ACTIVE_DURATION
player:SetAttribute("CombatPhase", "RECOVERY")
task.wait(math.max(0, COOLDOWN - WINDUP - ACTIVE_DURATION))
if player.Parent then player:SetAttribute("CombatPhase", "READY") end
end
attackEvent.OnServerEvent:Connect(function(player)
task.spawn(runAttack, player)
end)
Players.PlayerRemoving:Connect(function(player)
lastAttackAt[player] = nil
end)
Tip — hitboxは「攻撃中に調べる空間」
GetPartBoundsInBox()は、前方へずらした6 × 5 × 7 studsの箱と重なるPartを返す。見えないPartを作るのではなく、ACTIVEの0.12秒だけ同じ空間を何度か調べる。
hitThisSwingは1振り専用の命中済み表だ。Dummyの腕と胴が両方箱へ入っても、同じHumanoidへdamageを1回しか通さない。
攻撃要求を受けた後もcharacterを確認する
RemoteEventのcallbackで受け取るplayerはRobloxが決めるため、clientが別のPlayerを指定できない。ただし、要求の直後にResetしたり、Toolを外したりすることはできる。runAttack()は最初に現在のCharacter、生存中のHumanoid、装備中のOriginalSwordを調べ、0.28秒待った後にも同じCharacterが生存中か確認する。
cooldownの時刻もserverのos.clock()で記録する。clientから「前の攻撃から1秒経った」と送らないため、端末の時計を変えたりRemoteEventを連続送信したりしても0.95秒より早く次の攻撃へ入れない。
CombatPhaseはserverがPlayerへ設定するAttributeだ。clientのanimation markerとは別に、server側がWINDUP > ACTIVE > RECOVERY > READYを進める。見た目のmarkerが改造されても、serverの0.28秒WINDUPと0.12秒ACTIVEは変わらない。
Tip — cooldownは次の攻撃を受け付けない待ち時間
今回は1振り全体を
0.95秒に固定する。0.28秒のWINDUPと0.12秒のACTIVEを引いた残りがRECOVERYになり、合計が毎回同じになる。
3. 距離、向き、壁を確認する
箱へ入っただけではdamageを与えない。validTarget()が次も確認する。
- 距離が
8.5 studs以下 - プレイヤーの前方を表すdot値が
0.2以上 - Raycastが対象より先に壁へ当たらない
- 対象と攻撃者が生存中
OriginalSwordを装備中
Tip — dot値は向きの一致度
LookVector:Dot(offset.Unit)は、正面なら1、真横なら0、背後なら-1に近い。ここでは0.2未満を外すため、背後のDummyは箱へ少し入っても命中しない。

まずStop中にTestWall.Positionを7, 3.5, 10.5へ変える。PlayしてDummyの正面から振り、HPが75になることを確認しよう。もう一度Stopし、壁を0, 3.5, 10.5へ戻す。同じ立ち位置と向きから振っても頭上の表示が100 / 100なら、Raycastによる拒否が働いている。
壁を置いても減る場合は、TestWall.CanQueryがオンか、player、壁、Dummyが上面図のように一直線かを確認する。CanQueryはRaycastなどの空間問い合わせにPartを含める設定だ。
4. 100 HPから75 HPになるか確かめる
Playし、剣を装備して正面のDummyへ1回振る。

次の5ケースを順番に試そう。
- 正面の近距離で1回振ると
100 > 75 - 同じ振りの途中に連打しても
75のまま - 8.5 studsより離れると減らない
- Dummyへ背を向けると減らない
- 間へ壁を置くと減らない
一度StopしてDummyのHealthを100へ戻してから次のケースを試すと読みやすい。1回で50以下になる場合は、hitThisSwingがrunAttack()の外へ出ていないか、同じserver Scriptが2つないか確認する。
Outputで拒否理由を切り分ける
最初の確認では、TakeDamage()の直前へ一時的にprint(targetHumanoid.Parent.Name, targetHumanoid.Health)を置いてもよい。正面で1行だけ出て、距離外、背後、壁越しでは1行も出ないことを読む。確認後はprintを削除し、完成codeへ戻す。
攻撃自体が始まらない場合は、Play中のExplorerでOriginalSwordがBackpackではなくCharacterの直下へ移っているかを見る。SwordAttackがReplicatedStorage直下、SwordCombatServerがServerScriptService直下にあることも確認する。名前が同じでも置き場所が違うとWaitForChild()が待ち続ける。
Dummyを壁のすぐ後ろへ置くtestでは、壁のCanQueryをオンにする。オフだとRaycastが壁を無視するため、見た目では遮られていても命中する。通常のPartはオンが初期値だ。
5ケースを通した後は、Dummyを正面の開始位置へ戻してHealthを100にし、TestWall.Positionを7, 3.5, 10.5へ戻す。中央の通路を空けた編集状態でPlaceを保存しよう。保存後にもう一度Playし、正面の1振りが75で止まったらStopする。Play中の75は保存データへ残らないため、次の記事はHealth 100のDummyと横へ退避したTestWallから始められる。
これでdamageの正本がserverになった。次はTrainingDummyをAshKnightへ作り替え、40 studs以内の生存プレイヤーを見つけて6 studsまで追跡させる。