FiveM（QBCore）で自動販売機スクリプトを開発してみた

# はじめに
FiveMの街での飲食店のオープンしていないことが住民にとって不便でもあり、店員としても常に開けるのは負担が大きいため、自動販売機を設置してプレイヤーが購入できるようにした。自動販売機の在庫や売上の回収は、店員（特定ジョブ＋ランク）のみが出来るように作成した。飲食店の売上のために心なき（NPC)への販売模擬機能も搭載した。

# 自動販売機スクリプトの内容について
自動販売機の構成としては、以下の通りである。

【仕様】
　・プレイヤーが商品を購入できること
　・各自動販売機ごと、各商品ごとに在庫管理ができること
　・サーバ瞑想（FiveM再起動）時にランダム個数を販売する（NPCへの販売模擬）
　・在庫の補充は、特定ジョブができること
　・売上の回収は、特定ジョブ＋特定ランク以上ができること

【動作環境】
　・QBCore, ox_lib, ox_target
　　※心の目で見た際のメニューをox_libのメニューで実装。


## 動作画面
・商品購入の動作
　容量の関係で早送りみたいになっているが、操作としては、自動販売機に対して、心の目でアクセスして、メニューを表示。
商品を選び、個数を入力すると、支払いを行い、商品を受け取る。
（右上にあるBankのお金が減っているのが確認できる）

![vending_machine.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3868301/ff8892bb-9d80-077a-8027-3e16b035ca3c.gif)



## スクリプトの概要
![スクリーンショット 2024-11-14 10.34.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3868301/525b9923-c031-8c56-af9b-d7fb6da0766c.png)


## ソースコード（一部紹介）
ソースコードの一部を紹介する。
また、Scriptの配布やソースコードの公開については現在検討中のため、ポイントだけ記載する。

◼️ config.lua
取り扱う商品や、自動販売機の設定ファイル
商品名などはqb-core配下のitems.luaから取得しても良かったが、当時はそこまで理解しきれていなかったため、config.luaで記載している状況である。

```lua:config.lua
Config = {}

-- 商品名と日本語ラベルの対応表をConfigで管理
Config.ProductLabels = {
    --猫カフェ
    kira_kira_currye = 'キラキラカレー',
    strawberry_shortcake = 'いちごショートケーキ',
    doki_doki_pancakes = 'どきどきパンケーキ',
    --（省略）
}

--自動販売機の設定
Config.VendingMachines = {
    {
        id = 'Vending_Machine_001',-- 自動販売機のユニークID
        model = 'prop_vend_snak_01', -- 自動販売機のモデル 
        coords = vector3(-572.53, -1071.41, 21.33), -- 猫カフェ前
        heading = 0.0, --向き
        prices = {
            ["kira_kira_currye"] = 7500,
            ["strawberry_shortcake"] = 7500,
            ["doki_doki_pancakes"] = 7500,
            --(省略)
        },
        earnings = 0,
        allowedJob = 'uwu', --補充、売上金回収ができるジョブ設定
	    allowedGrade = 2, -- 売上金を回収できる最低ジョブレベルを指定
        autoSell = true,  -- ランダム販売を有効にする（心なきへの販売模擬）
        randomMin = 1,  -- ランダム販売の最小値
        randomMax = 10   -- ランダム販売の最大値
    },
    --(以下自動販売機設置分続く)
}
```

◼️vending_machine.json
自動販売機の在庫や売上金を管理しているDBファイル

```json:vending_machine.json
[
  {
    "earnings": 1725000,
    "inventory": {
      "kira_kira_currye": 124,
      "doki_doki_pancakes": 96,
      "strawberry_shortcake": 101
    },
    "id": "Vending_Machine_001"
  }
]
```
idがconfig.luaの自動販売機と紐づいている。
earningsは、自動販売機に入っている売上金額である。


:::note warn
スクリプト起動中にJSONファイルを直接書き換えても、反映されない。
メモリ上にロードされているため、書き換えたとしても、自動販売機が利用された際にメモリ上のデータに下記変わる。
反映させたい場合は、スクリプトの再起動が必要である。
:::

◼️自動販売機のpropの出現
```lua:client.lua
-- 自動販売機を生成し、配置
local vendingMachineEntity = CreateObject(vendingMachineModel, vendingMachineCoords.x, vendingMachineCoords.y, vendingMachineCoords.z, true, false, true)
SetEntityHeading(vendingMachineEntity, vendingMachineHeading)
FreezeEntityPosition(vendingMachineEntity, true)
SetModelAsNoLongerNeeded(vendingMachineModel)

```

◼️自動販売機に心の目で選択した際のメニュー表示（ox_targetを利用）

```lua:config.lua
-- ox_target Interaction (座標ベースのターゲットゾーンを追加)
for _, machineConfig in ipairs(Config.VendingMachines) do
    exports['ox_target']:addSphereZone({
        coords = machineConfig.coords,
        radius = 1.5,
        options = {
            {
                name = 'vendingMachineBuy',
                label = '商品を購入する',
                icon = 'fa-solid fa-shopping-cart',
                onSelect = function(data)
                    showProductMenu(machineConfig.coords)
                end
            },
            {
                name = 'vendingMachineRestock',
                label = '商品を補充する',
                icon = 'fa-solid fa-box',
                onSelect = function(data)
                    showRestockMenu(machineConfig.coords)
                end
            },
            {
                name = 'vendingMachineCollect',
                label = '売上金を回収する',
                icon = 'fa-solid fa-money-bill-wave',
                onSelect = function(data)
                    collectEarnings(machineConfig.coords)
                end
            }
        }
    })
end
```
上記コードの以下の部分で、ox_targetのメニューが表示できる座標と範囲を指定している。
最初は自動販売機のpropのIDに紐づけようとしたがうまくいかず、座標に変更した。
```lua:client.lua
    exports['ox_target']:addSphereZone({
        coords = machineConfig.coords,
        radius = 1.5,
```


◼️商品購入メニューと販売処理
商品メニュー（ox_target）の生成は、client.luaで実施する。
```lua:config.lua
table.insert(options, {
    title = label,
    description = " 在庫: " .. quantity .. "個, 価格: $" .. price,
    onSelect = function()
        local input = exports['ox_lib']:inputDialog('購入数量', {'数量'})
        if input and tonumber(input[1]) and tonumber(input[1]) > 0 then
            -- server.luaの販売処理を呼び出す
            TriggerServerEvent('vendingMachine:buyProduct', uniqueID, product, tonumber(input[1]), price)
        else
            exports['ox_lib']:notify({type = 'error', description = '無効な数量です。'})
        end
    end
})

-- 上記で作成したメニューを表示させる部分
if #options > 0 then
    exports['ox_lib']:registerContext({
        id = 'vending_machine_menu',
        title = '購入する商品を選択',
        options = options
    })

    exports['ox_lib']:showContext('vending_machine_menu')
else
    exports['ox_lib']:notify({type = 'error', description = '商品がありません。'})
end
```

アイテムの購入処理は、Server.luaで実施する。
```lua:server.lua
-- 商品購入処理（抜粋）
if machine and machine.inventory[productName] and machine.inventory[productName] >= quantity then
    local price = getProductPrice(uniqueID, productName) * quantity  -- Configから価格を取得
    if Player.Functions.RemoveMoney('bank', price, 'vendingMachine-purchase') then --cash or bank
        -- 自動販売機の商品ごとの在庫減少
        machine.inventory[productName] = machine.inventory[productName] - quantity
        -- 自動販売機の商品ごとの売上金額に加算
        machine.earnings = machine.earnings + price
        -- プレイヤーにアイテム付与
        Player.Functions.AddItem(productName, quantity)
        TriggerClientEvent('inventory:client:ItemBox', src, QBCore.Shared.Items[productName], "add")
        -- プレイヤーに購入メッセージ表示
        TriggerClientEvent('QBCore:Notify', src, productName .. 'を' .. quantity .. '個購入しました！', 'success')
        -- JSONファイルへの書き込み処理を呼び出し
        saveVendingData()
    else
        TriggerClientEvent('QBCore:Notify', src, '所持金が不足しています。', 'error')
    end
else
    TriggerClientEvent('QBCore:Notify', src, '在庫がありません。', 'error')
end
```

※QBCore:Notifyでメッセージを出力したり、exports['ox_lib']:notifyでメッセージを出力したり、チグハグな状態ではある...



◼️売上回収処理
特定のジョブかつジョブレベルだけ操作できるようにし、他のプレイヤーが操作しようとすると、ox_libでエラーメッセージを出力する。

```lua:client.lua
--プレイヤーのジョブとランクを取得
local playerJob = GetPlayerJob()

for _, machineConfig in ipairs(Config.VendingMachines) do
    if #(coords - machineConfig.coords) < 2.0 then
        if playerJob.name == machineConfig.allowedJob and playerJob.grade.level >= machineConfig.allowedGrade then
                --server.luaの売上を回収する処理を呼び出す
                TriggerServerEvent('vendingMachine:collectEarnings', machineConfig.id)
                return
        end
    end
end
exports['ox_lib']:notify({type = 'error', description = 'この操作を行う権限がありません。'})
```

# 少し宣伝
FiveMの35VILLAGE RPというサーバで遊んでいます！

https://discord.gg/N2E63F48zq

犯罪の種類が多かったり、白ジョブ、フリージョブもたくさんあるサーバです。
私が作った自作Scriptもいつくか入っているので、ぜひ遊びに来てください！

## 最後に
今回の自動販売機のスクリプトは、２作目のスクリプトでpropの生成などのやり方を把握することができた。
また、苦労した点としては、ox_targetを自動販売機のIDと紐づけて実施しようとしたがうまくいかず、座標に対してox_targetを指定することで、メニューを開けるようになった。
課題として、config.luaに自動販売機を追加した際に、JSONファイルも手動で記載する必要がある。一応、JSONファイルに自動販売機が存在しない場合にJSONファイルに追加する処理は入れているものの、期待した形式になっていない状況である。この辺りについては、随時改修していこうと思う。