PROJECT 02

2 / 11

Roblox Studioで剣の当たり判定を作る:serverでダメージを検証する方法

serverが攻撃間隔、範囲、向き、壁を検証し、TrainingDummyへ1振り1回だけ25ダメージを与える。

公開日 更新日
serverが剣攻撃を受け付け、ACTIVEで1回だけ25ダメージを与える図

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

serverが1回の攻撃を受け付けて25ダメージを確定するstate図

1. 最小テストmapとRemoteEventを用意する

最初に、同じ距離で正常命中と壁越しを比べられる小さなtest mapを作る。Workspaceへ次の4つを用意しよう。すでに床やSpawnLocationがある場合は、名前とPropertiesを次へ合わせればよい。

  • ArenaFloorPartPosition = 0, 0, 0Size = 82, 1, 56Anchored = true
  • CombatSpawnSpawnLocationPosition = 0, 1.5, 14Size = 7, 0.4, 7Anchored = trueNeutral = true
  • TrainingDummy > HumanoidRootPartPosition = 0, 3, 7Anchored = true
  • TestWallPartPosition = 0, 3.5, 10.5Size = 5, 6, 1Anchored = trueCanCollide = trueCanQuery = true

ArenaFloorTestWallは上部のHome > Partから追加し、Explorerで名前を変える。CombatSpawnWorkspace横のから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つが一直線になる。

Spawn、壁、TrainingDummyを一直線に置くcombat-02 test mapの上面図

通常命中を試すときはTestWall.PositionXだけを7へ変え、壁を横へ退避する。壁越しを試すときはX = 0へ戻す。Z = 10.5は変えない。Play開始後にキャラクターを中央へ寄せ、Dummyの正面を向けば、targetまで約7 studsの同じ条件で2ケースを比べられる。

Avatar > Character > Generate RigでR15のblock rigを追加し、TrainingDummyへ変える。HumanoidMaxHealthHealth100HumanoidRootPartAnchoredをオンにし、プレイヤーの正面へ置こう。この入口はRoblox公式のRig Generatorで案内されているCharacterボタンだ。

ReplicatedStorageRemoteEventを追加し、SwordAttackと名付ける。これはclientからserverへ攻撃要求を渡す1本の通路だ。

剣を装備したプレイヤーの正面にHP 100のtest targetを置いたPlay画面

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で攻撃の時間を所有する

ServerScriptServiceScriptを追加して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になり、合計が毎回同じになる。

serverがREADY、WINDUP、ACTIVEを進め、1回だけ25ダメージを確定する流れ

3. 距離、向き、壁を確認する

箱へ入っただけではdamageを与えない。validTarget()が次も確認する。

  • 距離が8.5 studs以下
  • プレイヤーの前方を表すdot値が0.2以上
  • Raycastが対象より先に壁へ当たらない
  • 対象と攻撃者が生存中
  • OriginalSwordを装備中

Tip — dot値は向きの一致度

LookVector:Dot(offset.Unit)は、正面なら1、真横なら0、背後なら-1に近い。ここでは0.2未満を外すため、背後のDummyは箱へ少し入っても命中しない。

プレイヤーとtargetの間を壁で遮り、攻撃後もHP 100のままになったPlay画面

まずStop中にTestWall.Position7, 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回振る。

正面の1回の剣攻撃でtest targetが100 HPから75 HPへ変わる実際のPlay画面

有効な1振りの後、test targetのHPが75で止まったPlay画面

次の5ケースを順番に試そう。

  1. 正面の近距離で1回振ると100 > 75
  2. 同じ振りの途中に連打しても75のまま
  3. 8.5 studsより離れると減らない
  4. Dummyへ背を向けると減らない
  5. 間へ壁を置くと減らない

一度StopしてDummyのHealthを100へ戻してから次のケースを試すと読みやすい。1回で50以下になる場合は、hitThisSwingrunAttack()の外へ出ていないか、同じserver Scriptが2つないか確認する。

Outputで拒否理由を切り分ける

最初の確認では、TakeDamage()の直前へ一時的にprint(targetHumanoid.Parent.Name, targetHumanoid.Health)を置いてもよい。正面で1行だけ出て、距離外、背後、壁越しでは1行も出ないことを読む。確認後はprintを削除し、完成codeへ戻す。

攻撃自体が始まらない場合は、Play中のExplorerでOriginalSwordがBackpackではなくCharacterの直下へ移っているかを見る。SwordAttackReplicatedStorage直下、SwordCombatServerServerScriptService直下にあることも確認する。名前が同じでも置き場所が違うとWaitForChild()が待ち続ける。

Dummyを壁のすぐ後ろへ置くtestでは、壁のCanQueryをオンにする。オフだとRaycastが壁を無視するため、見た目では遮られていても命中する。通常のPartはオンが初期値だ。

5ケースを通した後は、Dummyを正面の開始位置へ戻してHealthを100にし、TestWall.Position7, 3.5, 10.5へ戻す。中央の通路を空けた編集状態でPlaceを保存しよう。保存後にもう一度Playし、正面の1振りが75で止まったらStopする。Play中の75は保存データへ残らないため、次の記事はHealth 100のDummyと横へ退避したTestWallから始められる。

これでdamageの正本がserverになった。次はTrainingDummyをAshKnightへ作り替え、40 studs以内の生存プレイヤーを見つけて6 studsまで追跡させる。

公式リファレンス

画像の拡大表示