Robloxでゲージゲームを作成したいけど作り方がよくわからない人向け。

目次

実現したいこと

  • GUIのゲージを自動で伸縮させる
  • プレイヤーのタイミングで止めることができる
  • 基本的な処理はローカルで行うが、重要な判定はサーバで行う

UI作成

エクスプローラー内のStarterGui直下にScreenGuiを追加し、GUIオブジェクトを画面に配置していきます。ゲージの停止ボタンは画面タップで止めるようにするため画面を覆うようにします。

作成したUIのエクスプローラーオブジェクト階層は以下のようになります。

プロパティの値は基本的に自由ですが、ゲージゲームを作成する上で注意するオブジェクトが4つあります。

  • Frame/CenterLower/LeftGauge/2_Gauge
  • Frame/CenterLower/LeftGauge/2_Gauge/ImageLabel
  • Frame/CenterLower/RightGauge/2_Gauge
  • Frame/CenterLower/RightGauge/2_Gauge/ImageLabel

動きとしてはLeftGauge側は左から右へ、RightGauge側は右から左へゲージが伸びるようにします。

  • LeftGauge側:AnchorPointXは0、PositionXのScaleは0、SizeXはScaleを使用する
  • RightGauge側:AnchorPointXは1、PositionXのScaleは1、SizeXはScaleを使用する

ゲージの増減を制御する

作成したUIを制御するためのローカルスクリプトをStarterPlayer/StarterPlayerScripts直下に作成します。名前はGaugeにします。作成したファイルにUIの参照を取るための処理を記述します。

-- UI参照
-- Service
local Players = game:GetService("Players");
-- GUI
local ScreenGui = Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("ScreenGui");
local CenterMiddle = ScreenGui:WaitForChild("Frame"):WaitForChild("CenterMiddle");
local CenterLower = ScreenGui:WaitForChild("Frame"):WaitForChild("CenterLower");
-- CenterMiddle
local StopButton = CenterMiddle:WaitForChild("StopButton"):WaitForChild("TextButton");
-- CenterLower Left
local Timer = CenterLower:WaitForChild("Timer"):WaitForChild("TextLabel");
-- CenterLower Left
local LeftGauge = CenterLower:WaitForChild("LeftGauge"):WaitForChild("2_Gauge");
local LeftWin = CenterLower:WaitForChild("LeftGauge"):WaitForChild("3_Number"):WaitForChild("2_Win");
local LeftNumber = CenterLower:WaitForChild("LeftGauge"):WaitForChild("3_Number"):WaitForChild("3_TextLabel");
-- CenterLower Right
local RightGauge = CenterLower:WaitForChild("RightGauge"):WaitForChild("2_Gauge");
local RightWin = CenterLower:WaitForChild("RightGauge"):WaitForChild("3_Number"):WaitForChild("2_Win");
local RightNumber = CenterLower:WaitForChild("RightGauge"):WaitForChild("3_Number"):WaitForChild("3_TextLabel");

ゲージの溜まる時間やゲージゲームの最大時間などの定数や諸々の変数を記述します。

-- 定数
local GAUGE_MAX_TIME = 1; -- ゲージが最大値になる時間(秒)
local GAUGE_LIMIT_TIME = 3; -- ゲージゲームの時間(秒)
local GAUGE_VALUE_FORMAT = "%3d"; -- 値の表示形式
local SIDE_TYPE = { -- 左右の種類
    Left = 1,
    Right = 2,
};
-- 変数
local elapsedTime = 0; -- 経過時間
local gaugeValue = 0; -- ゲージの値
-- 左側
local isLeftUpdate = false; -- 更新フラグ(左)
local leftValue = 0; -- 値(左)
-- 右側
local isRightUpdate = false; -- 更新フラグ(右)
local rightValue = 0; -- 値(左)
-- 更新
local RunService = game:GetService("RunService");
local updateConnection = nil; -- 更新接続

更新処理を行う前に値の初期化を行います。ゲージのサイズやタイマーUIなどもここで初期化を行います。

-- 変数 初期化
elapsedTime = 0;
gaugeValue = 0;
isLeftUpdate = false;
leftValue = 0;
isRightUpdate = false;
rightValue = 0;
updateConnection = nil;
-- UI関連初期化
local size = LeftGauge.Size;
LeftGauge.Size = UDim2.new(0, size.X.Offset, size.Y.Scale, size.Y.Offset);
LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, gaugeValue);
LeftWin.Visible = false;
size = RightGauge.Size;
RightGauge.Size = UDim2.new(0, size.X.Offset, size.Y.Scale, size.Y.Offset);
RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, gaugeValue);
RightWin.Visible = false;
Timer.Text = tostring(GAUGE_LIMIT_TIME);
StopButton.Parent.Visible = false;

ボタン(画面タップ)で左のゲージが止まるようにします。

-- 停止処理
local function Stop(_type)
    if _type == SIDE_TYPE.Left then
        isLeftUpdate = false; -- 更新停止(左)
    elseif _type == SIDE_TYPE.Right then
        isRightUpdate = false; -- 更新停止(左)
    end 
end
-- 停止ボタン
StopButton.MouseButton1Down:Connect(function()
    Stop(SIDE_TYPE.Left);
end);

更新処理を記述します。ゲージゲームの時間を超えた場合は強制で0になるようにします。

-- 更新
if updateConnection == nil then
    isLeftUpdate = true;
    isRightUpdate = true;
    StopButton.Parent.Visible = true;
    updateConnection = RunService.Stepped:Connect(function(currentTime, deltaTime)
        if isLeftUpdate == true or isRightUpdate == true then
            elapsedTime = elapsedTime + deltaTime; -- 経過時間の加算
            local value = gaugeValue + deltaTime; -- ゲージの値の加算
            local per = 0; -- ゲージサイズ割合
            -- 値が最大値を超えたか
            local floor = math.floor(value / GAUGE_MAX_TIME);
            if floor > math.floor(gaugeValue / GAUGE_MAX_TIME) then
                gaugeValue = floor * GAUGE_MAX_TIME;    
                per = 1;
            else
                gaugeValue = value;
                per = gaugeValue / GAUGE_MAX_TIME;
                local floor = math.floor(per)
                per = per - floor;
            end
            local gauge = math.floor(per * 100); -- ゲージの値
            -- 制限時間を過ぎた
            if elapsedTime >= GAUGE_LIMIT_TIME then
                elapsedTime = GAUGE_LIMIT_TIME;
                per = 0;
                gauge = 0;
            end
            if isLeftUpdate then -- 左の更新
                leftValue = gauge;
                -- テキスト表示
                LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, leftValue);
                --ゲージ更新
                local size = LeftGauge.Size;
                LeftGauge.Size = UDim2.new(per, size.X.Offset, size.Y.Scale, size.Y.Offset);
                -- 制限時間を過ぎた
                if elapsedTime >= GAUGE_LIMIT_TIME then
                    Stop(SIDE_TYPE.Left);
                end
            end
            if isRightUpdate then -- 右の更新
                rightValue = gauge;
                -- テキスト表示
                RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, rightValue);
                --ゲージ更新
                local size = RightGauge.Size;
                RightGauge.Size = UDim2.new(per, size.X.Offset, size.Y.Scale, size.Y.Offset);
                -- 制限時間を過ぎた
                if elapsedTime >= GAUGE_LIMIT_TIME then
                    Stop(SIDE_TYPE.Right);
                end
            end
            Timer.Text = math.ceil(GAUGE_LIMIT_TIME - elapsedTime); -- 残り入力時間
        end
    end);
end

右側(CPU)も自動で止まるように宣言、初期化、更新に処理を追加する。

-- 宣言
...
-- CPU関連
local CPUGaugeStopRange = {20, 80}; -- CPUのゲージ停止範囲
local cpuGaugeValue = 0; -- CPU側の止める値
...
-- 変数 初期化
...
cpuGaugeValue = math.random(CPUGaugeStopRange[1], CPUGaugeStopRange[2]);    
...
-- 更新
...
            if isRightUpdate then -- 右の更新
                ...
                -- 制限時間を過ぎた
                if elapsedTime >= GAUGE_LIMIT_TIME then
                    Stop(SIDE_TYPE.Right);
                elseif gauge >= cpuGaugeValue then -- CPUの止める値を超えたら
                    Stop(SIDE_TYPE.Right);
                end
            end
...

左右のゲージが止まった時にどちらの値が大きいかのチェックを行い、UIを変更するようにします。

-- 停止処理
local function Stop(_type)
    ... 
    if isLeftUpdate == false and isRightUpdate == false then
        if leftValue > rightValue then
            LeftWin.Visible = true;
        elseif leftValue < rightValue then
            RightWin.Visible = true;
        end
    end
end

ここまでの実装でゲージゲームの基本部分ができました。

シーンを組み込む

初期化 → 更新 → 終了 → 初期化…とシーンを変更できるようにします。
必要なシーンは以下になります。

  • 初期化
  • 更新
  • 判定
  • 終了

まずシーンステートの定義をします。

-- 定数
local SCENE_STATE = { -- シーンステート
    Init = 1,
    Update = 2,
    Judge = 3,
    Finish = 4,
};
-- 変数
-- シーンステート
local changeStateFunc = nil; -- ステート変更関数の参照

次の各シーンの関数を作成します。

-- 初期化
local function Init()
end
-- 更新
local function Update()
end
-- 判定
local function Judge()
end
-- 終了
local function Finish()
end

ステートの変更関数を作成します。

-- ステート変更
local function changeState(_state)
    if _state == SCENE_STATE.Init then -- 初期化
        Init();
    elseif _state == SCENE_STATE.Update then -- 更新
        Update();
    elseif _state == SCENE_STATE.Judge then -- 判定
        Judge();
    elseif _state == SCENE_STATE.Finish then -- 終了
        Finish();
    else -- その他
        print("error change state"..tostring(_state));
    end
end

luaでは定義より上で呼び出せないです。

-- 例         
hoge();-- エラーになる
local function hoge() -- hoge関数
end
hoge(); -- hoge()を呼び出せる

そのため定義よりも上で関数を使用するには、変数に関数を入れて呼び出すようにします。

-- 例         
local hogeFunc = nil;
local function func()
    hogeFunc(); -- hoge()を呼び出せる
end
local function hoge() -- hoge関数
end
hogeFunc = hoge;

実際に変数から呼び出せるようにします。

-- 変数
-- シーンステート
local changeStateFunc = nil; -- ステート変更関数の参照
...
-- ステート変更
local function changeState(_state)
    ...
end
changeStateFunc = changeState;

変数やUI関連の初期化をInit()にまとめます。

-- 初期化
local function Init()
    -- 変数
    elapsedTime = 0;
    gaugeValue = 0;
    isLeftUpdate = false;
    leftValue = 0;
    isRightUpdate = false;
    rightValue = 0;
    updateConnection = nil;
    cpuGaugeValue = math.random(CPUGaugeStopRange[1], CPUGaugeStopRange[2]);    
    -- UI関連
    local size = LeftGauge.Size;
    LeftGauge.Size = UDim2.new(0, size.X.Offset, size.Y.Scale, size.Y.Offset);
    LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, gaugeValue);
    LeftWin.Visible = false;
    size = RightGauge.Size;
    RightGauge.Size = UDim2.new(0, size.X.Offset, size.Y.Scale, size.Y.Offset);
    RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, gaugeValue);
    RightWin.Visible = false;
    Timer.Text = tostring(GAUGE_LIMIT_TIME);
    StopButton.Parent.Visible = false;
    wait();
    changeStateFunc(SCENE_STATE.Update);
end

更新処理をUpdate()にまとめます。

-- 更新
local function Update()
    isLeftUpdate = true;
    isRightUpdate = true;
    StopButton.Parent.Visible = true;
    updateConnection = RunService.Stepped:Connect(function(currentTime, deltaTime)
        if isLeftUpdate == true or isRightUpdate == true then
            elapsedTime = elapsedTime + deltaTime; -- 経過時間の加算
            ...
            Timer.Text = math.ceil(GAUGE_LIMIT_TIME - elapsedTime); -- 残り入力時間
        end
    end);
end

判定処理をStop()からJudge()に移動させます。

-- 停止処理
local function Stop(_type)
    ...
    -- 左右のゲージが止まった
    if isLeftUpdate == false and isRightUpdate == false then
        changeStateFunc(SCENE_STATE.Judge);
    end
end
...
-- 判定
local function Judge()
    -- 値の大きい方のUIを変更する(引き分けは何もしない)
    if leftValue > rightValue then
        LeftWin.Visible = true;
    elseif leftValue < rightValue then
        RightWin.Visible = true;
    end
    wait(3); -- 待機3秒
    changeStateFunc(SCENE_STATE.Finish);
end

終了処理をFinish()へまとめます。

-- 終了
local function Finish()
    if updateConnection ~= nil then
        updateConnection:Disconnect();
        updateConnection = nil;
    end
    wait(1); -- 待機1秒
    changeStateFunc(SCENE_STATE.Init);
end

最後に一番下に初期化シーンを呼び出す処理を追加します。

-- 最初のシーンは初期化から始める
changeStateFunc(SCENE_STATE.Init);

これで何度もゲージゲームを遊ぶことができるようになりました。

様々な判定や処理をサーバに任せる


今までローカルのみで処理や判定を行ってきましたが、ローカルはデータ改竄、漏洩など安心して使うには少し頼りない気がします。そこでプレイヤーが触ることのできないサーバへ色々な処理を移動させます。
移動させる処理は以下になります。

  • シーンステート

  • ゲージの溜まる時間やゲージゲーム時間

  • CPUの値

  • ゲージを止めた時の値チェック


サーバ処理を行うためのスクリプトをServerScriptService直下に作成します。名前はGaugeにします。
ローカルスクリプトからサーバで使用できるものをコピーします。

-- 定数
local GAUGE_MAX_TIME = 1; -- ゲージが最大値になる時間(秒)
local GAUGE_LIMIT_TIME = 3; -- ゲージゲームの時間(秒)
local SIDE_TYPE = { -- 左右の種類
    None = 0,
    Left = 1,
    Right = 2,
};
local SCENE_STATE = { -- シーンステート
    Init = 1,
    Update = 2,
    Judge = 3,
    Finish = 4,
};

-- 変数
local leftValue = 0; -- 左の値
local rightValue = 0; -- 右の値
local winSide = 0; -- 勝った方
-- CPU関連
local CPUGaugeStopRange = {20, 80}; -- CPUのゲージ停止範囲
local cpuGaugeValue = 0; -- CPU側の止める値
-- シーンステート
local changeStateFunc = nil; -- ステート変更関数の参照

-- 初期化
local function Init()
    -- 変数
    leftValue = -1;
    rightValue = -1;
    winSide = SIDE_TYPE.None;
    cpuGaugeValue = math.random(CPUGaugeStopRange[1], CPUGaugeStopRange[2]);    
end
-- 更新
local function Update()
end
-- 判定
local function Judge()
end
-- 終了
local function Finish()
end

-- ステート変更
local function changeState(_state)
    if _state == SCENE_STATE.Init then -- 初期化
        Init();
    elseif _state == SCENE_STATE.Update then -- 更新
        Update();
    elseif _state == SCENE_STATE.Judge then -- 判定
        Judge();
    elseif _state == SCENE_STATE.Finish then -- 終了
        Finish();
    else -- その他
        print("error change state"..tostring(_state));
    end
end
changeStateFunc = changeState;

-- 最初のシーンは初期化から始める
changeStateFunc(SCENE_STATE.Init);

再生を行うとローカルスクリプトでゲージ増減が動き出しているのでシーン遷移を行わないようにします。
ローカルスクリプトのsceneState変数changeStateFunc変数changeState関数wait()を全て削除します。

local sceneState = SCENE_STATE.Init; -- 現在のシーンステート
local changeStateFunc = nil; -- ステート変更関数の参照
-- ステート変更
local function changeState(_state)
...
end
wait(数値);

ローカルスクリプトの一番下に記述した初期化呼び出しは以下のように変更します

-- 最初のシーンは初期化から始める
changeStateFunc(SCENE_STATE.Init);
↓
-- 最初のシーンは初期化から始める
Init();

サーバ <=> クライアントでデータやり取りを行うことができるRemoteEventの機能を使用し、シーンの遷移を実装します。
ReplicatedStorage直下にRemoteEventを作成します。
作成するRemoteEventは以下になります。


  • InitEvent : 初期化イベント(サーバ → クライアント、クライアント → サーバ)

  • UpdateEvent : 更新イベント(サーバ → クライアント)

  • JudgeEvent : 判定イベント(サーバ → クライアント)

  • FinishEvent : 終了イベント(サーバ → クライアント)

  • StopEvent : 停止イベント(クライアント → サーバ)


サーバとクライアントのGaugeスクリプトに作成したRemoteEventの参照を取得するソースを追加します。

-- Service
local ReplicatedStorage = game:GetService("ReplicatedStorage");
-- Event
local InitEvent = ReplicatedStorage:WaitForChild("InitEvent");
local UpdateEvent = ReplicatedStorage:WaitForChild("UpdateEvent");
local JudgeEvent = ReplicatedStorage:WaitForChild("JudgeEvent");
local FinishEvent = ReplicatedStorage:WaitForChild("FinishEvent");
local StopEvent = ReplicatedStorage:WaitForChild("StopEvent");

ローカルスクリプトにサーバから呼び出すイベントを登録します。

-- 初期化
local function Init()
    ...
end
InitEvent.OnClientEvent:Connect(Init);
-- 更新
local function Update()
    ...
end
UpdateEvent.OnClientEvent:Connect(Update);
-- 判定
local function Judge()
    ...
end
JudgeEvent.OnClientEvent:Connect(Judge);
-- 終了
local function Finish()
    ...
end
FinishEvent.OnClientEvent:Connect(Finish);

サーバスクリプトにゲージを止めた時にデータを受け取る関数と呼び出しイベントを追加します。

-- 停止
local function Stop()
end
StopEvent.OnServerEvent:Connect(Stop);

サーバスクリプトでシーンの遷移を調整します。それぞれのイベントで必要な引数を追加します。

-- 初期化
local function Init()
    -- 変数
    leftValue = -1;
    rightValue = -1;
    winSide = SIDE_TYPE.None;
    cpuGaugeValue = math.random(CPUGaugeStopRange[1], CPUGaugeStopRange[2]);    
    -- 初期化イベント(クライアント)
    InitEvent:FireAllClients(GAUGE_MAX_TIME, GAUGE_LIMIT_TIME, cpuGaugeValue);
    -- ステート変更
    changeStateFunc(SCENE_STATE.Update);
end
-- 更新
local function Update()
    -- 待ち
    wait(1);
    -- 更新イベント(クライアント)
    UpdateEvent:FireAllClients();
end
-- 判定
local function Judge()
    if leftValue > rightValue then -- 左の勝ち
        winSide = SIDE_TYPE.Left;
    elseif leftValue = 0 and rightValue >= 0 then
        -- ステート変更
        changeStateFunc(SCENE_STATE.Judge);
    end 
end

サーバスクリプトで引数を追加したため、ローカルスクリプトも引数を追加します。

-- 初期化
local function Init(_gaugeMacTime, _gaugeLimitTime, _cpuGaugeValue)
    -- 定数
    GAUGE_MAX_TIME = _gaugeMacTime;
    GAUGE_LIMIT_TIME = _gaugeLimitTime;
    -- 変数
    cpuGaugeValue = _cpuGaugeValue; 
    ...
end
-- 判定
local function Judge(_winSide)
    -- 勝った方のUIを変更する(引き分けは何もしない)
    if _winSide == SIDE_TYPE.Left then
        LeftWin.Visible = true;
    elseif _winSide == SIDE_TYPE.Right then
        RightWin.Visible = true;
    end
    -- 判定イベント(クライアント)
    JudgeEvent:FireAllClients(winSide);
end
...
Init(-1, -1, -1);

ゲージ停止のイベントをローカルスクリプトに追加します。

-- 停止処理
local function Stop(_type)
    if _type == SIDE_TYPE.Left then
        isLeftUpdate = false; -- 更新停止(左)
        -- 停止イベント(サーバ)
        StopEvent:FireServer(_type, leftValue);
    elseif _type == SIDE_TYPE.Right then
        isRightUpdate = false; -- 更新停止(左)
        -- 停止イベント(サーバ)
        StopEvent:FireServer(_type, rightValue);
    end 
end

そのまま再生させるとローカルの初期化が終わる前にサーバの更新が始まることがあります。同期を取るためにローカルの初期化後にサーバへイベントを使い同期させます。
ローカルスクリプトは以下のようにします。

-- 初期化
local function Init(_gaugeMaxTime, _gaugeLimitTime, _cpuGaugeValue)
    ...
    StopButton.Parent.Visible = false;  
    if _gaugeMaxTime >= 0 and _gaugeLimitTime >= 0 and _cpuGaugeValue >= 0 then
        -- サーバイベント(初期化)
        InitEvent:FireServer();
    end
end

サーバスクリプトは以下のようにします。

-- 初期化
local function Init()
    ...
    -- 初期化イベント(クライアント)
    InitEvent:FireAllClients(GAUGE_MAX_TIME, GAUGE_LIMIT_TIME, cpuGaugeValue);
end
local function InitEnd(_player)
    -- ステート変更
    changeStateFunc(SCENE_STATE.Update);
end
InitEvent.OnServerEvent:Connect(InitEnd);
-- 更新
local function Update()
    -- 更新イベント(クライアント)
    UpdateEvent:FireAllClients();
end

これで今までと同じようにゲージゲームを遊ぶことができます。
次はローカルから来たデータが改竄されていないかのチェックを行います。やり方は色々ありますが今回はUpdateの呼び出し時間を使います。
ローカルスクリプトに前フレームとの時間差を格納する配列を用意し、Stop()Init()Update()にそれぞれ処理を追記します。

-- 変数
local deltaTimes = {}; -- 経過時間配列
...
-- 停止処理
local function Stop(_type)
    if _type == SIDE_TYPE.Left then
        ...
        StopEvent:FireServer(_type, leftValue, deltaTimes);
    elseif _type == SIDE_TYPE.Right then
        ...
        StopEvent:FireServer(_type, rightValue, deltaTimes);
    end 
end
...
-- 初期化
local function Init(_gaugeMaxTime, _gaugeLimitTime, _cpuGaugeValue)
    ...
    deltaTimes = {};
    ...
end
-- 更新
local function Update()
    if updateConnection == nil then
        isLeftUpdate = true;
        isRightUpdate = true;
        StopButton.Parent.Visible = true;
        updateConnection = RunService.Stepped:Connect(function(currentTime, deltaTime)
            if isLeftUpdate == true or isRightUpdate == true then
                elapsedTime += deltaTime; -- 経過時間の加算
                table.insert(deltaTimes, deltaTime);
                ...
            end
        end);
    end
end

時間切れのチェックも行うのでelapsedTimeもサーバに渡すようにします。

-- 停止処理
local function Stop(_type)
    ...
    if _type == SIDE_TYPE.Left then
        isLeftUpdate = false; -- 更新停止(左)
        -- 停止イベント(サーバ)
        StopEvent:FireServer(_type, leftValue, deltaTimes, elapsedTime);
    elseif _type == SIDE_TYPE.Right then
        isRightUpdate = false; -- 更新停止(左)
        -- 停止イベント(サーバ)
        StopEvent:FireServer(_type, rightValue, deltaTimes, elapsedTime);
    end 
end

ゲージの割合計算の一部をサーバとローカルで使用したいため、まずは関数を作成します。
制限時間を過ぎた処理は引数や返り値が増えるため入れないようにします。

-- ゲージの割合計算
local function calcGaugeRatio(_deltaTime, _gaugeValue, _gaugeMaxTime)
    local per = 0; -- ゲージサイズ割合
    local value = _gaugeValue + _deltaTime; -- ゲージの値の加算
    -- 値が最大値を超えたか
    local floor = math.floor(value / _gaugeMaxTime);
    if floor > math.floor(_gaugeValue / _gaugeMaxTime) then
        _gaugeValue = floor * _gaugeMaxTime;    
        per = 1;
    else
        _gaugeValue = value;
        per = _gaugeValue / _gaugeMaxTime;
        local floor = math.floor(per);
        per = per - floor;
    end
    return _gaugeValue, per;
end

Update()の該当箇所をcalcGaugeRatio()に入れ替えます。

-- ゲージの割合計算
local function calcGaugeRatio(_deltaTime, _gaugeValue, _gaugeMaxTime)
    ...
end
-- 更新
local function Update()
    if updateConnection == nil then
        ...
        updateConnection = RunService.Stepped:Connect(function(currentTime, deltaTime)
            if isLeftUpdate == true or isRightUpdate == true then
                elapsedTime += deltaTime; -- 経過時間の加算
                table.insert(deltaTimes, deltaTime);
                local per = 0; -- ゲージサイズ割合
                -- ゲージの割合計算
                gaugeValue, per = calcGaugeRatio(deltaTime, gaugeValue, GAUGE_MAX_TIME);
                local gauge = math.floor(per * 100); -- ゲージの値
                -- 制限時間を過ぎた
                if elapsedTime >= GAUGE_LIMIT_TIME then
                    elapsedTime = GAUGE_LIMIT_TIME;
                    per = 0;
                    gauge = 0;
                end
                ...
            end
        end);
    end
end

ReplicatedStorage直下にModuleScriptを作成します。名前はGaugeModuleにします。
処理はcalcGaugeRatio()を移動させ、モジュールから呼び出せるようにします。

local GaugeModule = {}
-- ゲージの割合計算
function GaugeModule:calcGaugeRatio(_deltaTime, _gaugeValue, _gaugeMaxTime)
    local per = 0; -- ゲージサイズ割合
    local value = _gaugeValue + _deltaTime; -- ゲージの値の加算
    -- 値が最大値を超えたか
    local floor = math.floor(value / _gaugeMaxTime);
    if floor > math.floor(_gaugeValue / _gaugeMaxTime) then
        _gaugeValue = floor * _gaugeMaxTime;    
        per = 1;
    else
        _gaugeValue = value;
        per = _gaugeValue / _gaugeMaxTime;
        local floor = math.floor(per);
        per = per - floor;
    end
    return _gaugeValue, per;
end
return GaugeModule

作成したGaugeModuleモジュールをローカルスクリプトで使用できるようにします。

-- Service
local ReplicatedStorage = game:GetService("ReplicatedStorage");
-- Module
local GaugeModule = require(ReplicatedStorage:WaitForChild("GaugeModule"));
-- 更新
local function Update()
    if updateConnection == nil then
        ...
        updateConnection = RunService.Stepped:Connect(function(currentTime, deltaTime)
            if isLeftUpdate == true or isRightUpdate == true then
                ...
                -- ゲージの割合計算
                gaugeValue, per = GaugeModule:calcGaugeRatio(deltaTime, gaugeValue, GAUGE_MAX_TIME);
                ...
            end
        end);
    end
end

次にサーバ側のチェック処理を作成します。
GaugeModuleモジュールをサーバスクリプトでも使用できるようにします。

-- Service
local ReplicatedStorage = game:GetService("ReplicatedStorage");
-- Module
local GaugeModule = require(ReplicatedStorage:WaitForChild("GaugeModule"));

Stop()の引数を増やします。

-- 停止
local function Stop(_player, _side, _value, _deltaTimes, _elapsedTime)
    ...
end

Stop()にチェック処理を追加します。チェック結果が偽だった場合は制限時間を過ぎたと同様に値を0にします。

-- 停止
local function Stop(_player, _side, _value, _deltaTimes, _elapsedTime)
    local value = 0;
    local per = 0;
    for i = 1, #_deltaTimes do
        value, per = GaugeModule:calcGaugeRatio(_deltaTimes[i], value, GAUGE_MAX_TIME);
    end
    local gauge = math.floor(per * 100); -- ゲージの値
    -- 制限時間を過ぎた
    if _elapsedTime >= GAUGE_LIMIT_TIME then
        _elapsedTime = GAUGE_LIMIT_TIME;
        gauge = 0;
    end
    -- チェック結果エラー    
    if gauge ~= _value then
        _value = 0;
    end
    ...
end

これでチェック処理はできましたが、チェック後のゲージの値がローカルに反映されていません。
サーバスクリプトのJudge()JudgeEventでチェック後の値を送るようにします。

-- 判定
local function Judge()
    ...
    -- 判定イベント(クライアント)
    JudgeEvent:FireAllClients(winSide, leftValue, rightValue);
    ...
end

ローカルスクリプトのJudge()に引数を追加します。

-- 判定
local function Judge(_winSide, _leftValue, _rightValue)
    ...
end

受け取った値をゲージに反映させます。

-- 判定
local function Judge(_winSide, _leftValue, _rightValue)
    leftValue = _leftValue;
    rightValue = _rightValue;
    -- テキスト表示
    LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, leftValue);
    RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, rightValue);
    --ゲージ更新
    local size = LeftGauge.Size;
    LeftGauge.Size = UDim2.new(leftValue / 100, size.X.Offset, size.Y.Scale, size.Y.Offset);
    size = RightGauge.Size;
    RightGauge.Size = UDim2.new(rightValue / 100, size.X.Offset, size.Y.Scale, size.Y.Offset);  
    ...
end

これで一通りの実装ができました。

作成したスクリプト


ローカルスクリプト Path:StarterPlayer/StarterPlayerScripts/Gauge

-- Service
local Players = game:GetService("Players");
local ReplicatedStorage = game:GetService("ReplicatedStorage");
-- Module
local GaugeModule = require(ReplicatedStorage:WaitForChild("GaugeModule"));
-- Event
local InitEvent = ReplicatedStorage:WaitForChild("InitEvent");
local UpdateEvent = ReplicatedStorage:WaitForChild("UpdateEvent");
local JudgeEvent = ReplicatedStorage:WaitForChild("JudgeEvent");
local FinishEvent = ReplicatedStorage:WaitForChild("FinishEvent");
local StopEvent = ReplicatedStorage:WaitForChild("StopEvent");
-- GUI
local ScreenGui = Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("ScreenGui");
local CenterMiddle = ScreenGui:WaitForChild("Frame"):WaitForChild("CenterMiddle");
local CenterLower = ScreenGui:WaitForChild("Frame"):WaitForChild("CenterLower");
-- CenterMiddle
local StopButton = CenterMiddle:WaitForChild("StopButton"):WaitForChild("TextButton");
-- CenterLower Time
local Timer = CenterLower:WaitForChild("Timer"):WaitForChild("TextLabel");
-- CenterLower Left
local LeftGauge = CenterLower:WaitForChild("LeftGauge"):WaitForChild("2_Gauge");
local LeftWin = CenterLower:WaitForChild("LeftGauge"):WaitForChild("3_Number"):WaitForChild("2_Win");
local LeftNumber = CenterLower:WaitForChild("LeftGauge"):WaitForChild("3_Number"):WaitForChild("3_TextLabel");
-- CenterLower Right
local RightGauge = CenterLower:WaitForChild("RightGauge"):WaitForChild("2_Gauge");
local RightWin = CenterLower:WaitForChild("RightGauge"):WaitForChild("3_Number"):WaitForChild("2_Win");
local RightNumber = CenterLower:WaitForChild("RightGauge"):WaitForChild("3_Number"):WaitForChild("3_TextLabel");

-- 定数
local GAUGE_MAX_TIME = 1; -- ゲージが最大値になる時間(秒)
local GAUGE_LIMIT_TIME = 3; -- ゲージゲームの時間(秒)
local GAUGE_VALUE_FORMAT = "%3d"; -- 値の表示形式
local SIDE_TYPE = { -- 左右の種類
    None = 0,
    Left = 1,
    Right = 2,
};
-- 変数
local elapsedTime = 0; -- 経過時間
local gaugeValue = 0; -- ゲージの値
local deltaTimes = {}; -- 経過時間配列
-- 左側
local isLeftUpdate = false; -- 更新フラグ(左)
local leftValue = 0; -- 値(左)
-- 右側
local isRightUpdate = false; -- 更新フラグ(右)
local rightValue = 0; -- 値(左)
-- 更新
local RunService = game:GetService("RunService");
local updateConnection = nil; -- 更新接続
-- CPU関連
local cpuGaugeValue = 0; -- CPU側の止める値

-- 停止処理
local function Stop(_side)
    if _side == SIDE_TYPE.Left then
        isLeftUpdate = false; -- 更新停止(左)
        -- 停止イベント(サーバ)
        StopEvent:FireServer(_side, leftValue, deltaTimes, elapsedTime);
    elseif _side == SIDE_TYPE.Right then
        isRightUpdate = false; -- 更新停止(左)
        -- 停止イベント(サーバ)
        StopEvent:FireServer(_side, rightValue, deltaTimes, elapsedTime);
    end 
end
-- 停止ボタン
StopButton.MouseButton1Down:Connect(function()
    Stop(SIDE_TYPE.Left);
end);
-- 初期化
local function Init(_gaugeMaxTime, _gaugeLimitTime, _cpuGaugeValue)
    -- 定数
    GAUGE_MAX_TIME = _gaugeMaxTime;
    GAUGE_LIMIT_TIME = _gaugeLimitTime;
    -- 変数
    cpuGaugeValue = _cpuGaugeValue; 
    elapsedTime = 0;
    gaugeValue = 0;
    isLeftUpdate = false;
    leftValue = 0;
    isRightUpdate = false;
    rightValue = 0;
    updateConnection = nil;
    deltaTimes = {};
    -- UI関連
    local size = LeftGauge.Size;
    LeftGauge.Size = UDim2.new(0, size.X.Offset, size.Y.Scale, size.Y.Offset);
    LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, gaugeValue);
    LeftWin.Visible = false;
    size = RightGauge.Size;
    RightGauge.Size = UDim2.new(0, size.X.Offset, size.Y.Scale, size.Y.Offset);
    RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, gaugeValue);
    RightWin.Visible = false;
    Timer.Text = tostring(GAUGE_LIMIT_TIME);
    StopButton.Parent.Visible = false;
    if _gaugeMaxTime >= 0 and _gaugeLimitTime >= 0 and _cpuGaugeValue >= 0 then
        -- サーバイベント(初期化)
        InitEvent:FireServer();
    end
end
InitEvent.OnClientEvent:Connect(Init);
-- 更新
local function Update()
    if updateConnection == nil then
        isLeftUpdate = true;
        isRightUpdate = true;
        StopButton.Parent.Visible = true;
        updateConnection = RunService.Stepped:Connect(function(currentTime, deltaTime)
            if isLeftUpdate == true or isRightUpdate == true then
                elapsedTime += deltaTime; -- 経過時間の加算
                table.insert(deltaTimes, deltaTime);
                local per = 0; -- ゲージサイズ割合
                -- ゲージの割合計算
                gaugeValue, per = GaugeModule:calcGaugeRatio(deltaTime, gaugeValue, GAUGE_MAX_TIME);
                local gauge = math.floor(per * 100); -- ゲージの値
                -- 制限時間を過ぎた
                if elapsedTime >= GAUGE_LIMIT_TIME then
                    elapsedTime = GAUGE_LIMIT_TIME;
                    per = 0;
                    gauge = 0;
                end
                if isLeftUpdate then -- 左の更新
                    leftValue = gauge;
                    -- テキスト表示
                    LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, leftValue);
                    --ゲージ更新
                    local size = LeftGauge.Size;
                    LeftGauge.Size = UDim2.new(per, size.X.Offset, size.Y.Scale, size.Y.Offset);
                    -- 制限時間を過ぎた
                    if elapsedTime >= GAUGE_LIMIT_TIME then
                        Stop(SIDE_TYPE.Left);
                    end
                end
                if isRightUpdate then -- 右の更新
                    rightValue = gauge;
                    -- テキスト表示
                    RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, rightValue);
                    --ゲージ更新
                    local size = RightGauge.Size;
                    RightGauge.Size = UDim2.new(per, size.X.Offset, size.Y.Scale, size.Y.Offset);
                    -- 制限時間を過ぎた
                    if elapsedTime >= GAUGE_LIMIT_TIME then
                        Stop(SIDE_TYPE.Right);
                    elseif gauge >= cpuGaugeValue then -- CPUの止める値を超えたら
                        Stop(SIDE_TYPE.Right);
                    end
                end
                Timer.Text = math.ceil(GAUGE_LIMIT_TIME - elapsedTime); -- 残り入力時間
            end
        end);
    end
end
UpdateEvent.OnClientEvent:Connect(Update);
-- 判定
local function Judge(_winSide, _leftValue, _rightValue)
    leftValue = _leftValue;
    rightValue = _rightValue;
    -- テキスト表示
    LeftNumber.Text = string.format(GAUGE_VALUE_FORMAT, leftValue);
    RightNumber.Text = string.format(GAUGE_VALUE_FORMAT, rightValue);
    --ゲージ更新
    local size = LeftGauge.Size;
    LeftGauge.Size = UDim2.new(leftValue / 100, size.X.Offset, size.Y.Scale, size.Y.Offset);
    size = RightGauge.Size;
    RightGauge.Size = UDim2.new(rightValue / 100, size.X.Offset, size.Y.Scale, size.Y.Offset);  
    -- 勝った方のUIを変更する(引き分けは何もしない)
    if _winSide == SIDE_TYPE.Left then
        LeftWin.Visible = true;
    elseif _winSide == SIDE_TYPE.Right then
        RightWin.Visible = true;
    end
end
JudgeEvent.OnClientEvent:Connect(Judge);
-- 終了
local function Finish()
    if updateConnection ~= nil then
        updateConnection:Disconnect();
        updateConnection = nil;
    end
end
FinishEvent.OnClientEvent:Connect(Finish);

Init(-1, -1, -1);

サーバスクリプト Path:ServerScriptService/Gauge

-- Service
local ReplicatedStorage = game:GetService("ReplicatedStorage");
-- Module
local GaugeModule = require(ReplicatedStorage:WaitForChild("GaugeModule"));
-- Event
local InitEvent = ReplicatedStorage:WaitForChild("InitEvent"); -- 初期化イベント
local UpdateEvent = ReplicatedStorage:WaitForChild("UpdateEvent"); -- 更新イベント
local JudgeEvent = ReplicatedStorage:WaitForChild("JudgeEvent"); -- 判定イベント
local FinishEvent = ReplicatedStorage:WaitForChild("FinishEvent"); -- 終了イベント
local StopEvent = ReplicatedStorage:WaitForChild("StopEvent"); -- 停止イベント
-- 定数
local GAUGE_MAX_TIME = 1; -- ゲージが最大値になる時間(秒)
local GAUGE_LIMIT_TIME = 3; -- ゲージゲームの時間(秒)
local SIDE_TYPE = { -- 左右の種類
    None = 0,
    Left = 1,
    Right = 2,
};
local SCENE_STATE = { -- シーンステート
    Init = 1,
    Update = 2,
    Judge = 3,
    Finish = 4,
};
-- 変数
local leftValue = 0; -- 左の値
local rightValue = 0; -- 右の値
local winSide = 0; -- 勝った方
-- CPU関連
local CPUGaugeStopRange = {20, 80}; -- CPUのゲージ停止範囲
local cpuGaugeValue = 0; -- CPU側の止める値
-- シーンステート
local changeStateFunc = nil; -- ステート変更関数の参照

-- 初期化
local function Init()
    -- 変数
    leftValue = -1;
    rightValue = -1;
    winSide = SIDE_TYPE.None;
    cpuGaugeValue = math.random(CPUGaugeStopRange[1], CPUGaugeStopRange[2]);    
    -- 初期化イベント(クライアント)
    InitEvent:FireAllClients(GAUGE_MAX_TIME, GAUGE_LIMIT_TIME, cpuGaugeValue);
end
local function InitEnd(_player)
    -- ステート変更
    changeStateFunc(SCENE_STATE.Update);
end
InitEvent.OnServerEvent:Connect(InitEnd);
-- 更新
local function Update()
    -- 更新イベント(クライアント)
    UpdateEvent:FireAllClients();
end
-- 判定
local function Judge()
    if leftValue > rightValue then -- 左の勝ち
        winSide = SIDE_TYPE.Left;
    elseif leftValue = GAUGE_LIMIT_TIME then
        _elapsedTime = GAUGE_LIMIT_TIME;
        gauge = 0;
    end
    -- チェック結果エラー    
    if gauge ~= _value then
        _value = 0;
    end
    if _side == SIDE_TYPE.Left then
        leftValue = _value;
    elseif _side == SIDE_TYPE.Right then
        rightValue = _value;
    end
    --  両値が0以上
    if leftValue >= 0 and rightValue >= 0 then
        -- ステート変更
        changeStateFunc(SCENE_STATE.Judge);
    end 
end
StopEvent.OnServerEvent:Connect(Stop);
-- ステート変更
local function changeState(_state)
    if _state == SCENE_STATE.Init then -- 初期化
        Init();
    elseif _state == SCENE_STATE.Update then -- 更新
        Update();
    elseif _state == SCENE_STATE.Judge then -- 判定
        Judge();
    elseif _state == SCENE_STATE.Finish then -- 終了
        Finish();
    else -- その他
        print("error change state"..tostring(_state));
    end
end
changeStateFunc = changeState;
-- 待ち
wait(3);
-- 最初のシーンは初期化から始める
changeStateFunc(SCENE_STATE.Init);

モジュールスクリプト Path:ReplicatedStorage/GaugeModule

local GaugeModule = {}

-- ゲージの割合計算
function GaugeModule:calcGaugeRatio(_deltaTime, _gaugeValue, _gaugeMaxTime)
    local per = 0; -- ゲージサイズ割合
    local value = _gaugeValue + _deltaTime; -- ゲージの値の加算
    -- 値が最大値を超えたか
    local floor = math.floor(value / _gaugeMaxTime);
    if floor > math.floor(_gaugeValue / _gaugeMaxTime) then
        _gaugeValue = floor * _gaugeMaxTime;    
        per = 1;
    else
        _gaugeValue = value;
        per = _gaugeValue / _gaugeMaxTime;
        local floor = math.floor(per);
        per = per - floor;
    end
    return _gaugeValue, per;
end

return GaugeModule