메인 콘텐츠로 건너뛰기
이 가이드는 PlayWave 플러그인 없이 직접 설치하는 방법입니다. 플러그인을 사용하는 권장 방법은 빠른 시작 가이드를 참고하세요.

프로젝트 구조

ServerScriptService
Script
PlaywaveService (ModuleScript)
StarterPlayer
StarterPlayerScripts
Client (LocalScript)
RedeemUI (ModuleScript)
스크립트ClassName역할
ScriptScript메인 서버 로직, PlaywaveService 초기화
PlaywaveServiceModuleScriptAPI 통신, 세션 관리,
ClientLocalScript서버에 클라이언트 준비 신호 전송
RedeemUIModuleScriptPC방 상태 표시 UI 선택

PlaywaveService 모듈

핵심 모듈입니다. API 통신, OTT 검증, 하트비트 루프, 세션 종료를 처리합니다.
API_URLAPI_KEY를 발급받은 값으로 변경하세요. API Key는 서버 사이드에서만 사용해야 합니다.

API 서버 주소

환경URL상태
Devhttps://wave-api.playwave.dev/v1사용 가능
QAhttps://wave-api-qa.playwave.dev/v1준비 중
Livehttps://wave-api.playwave.io/v1준비 중
-- PlaywaveService (ModuleScript)
-- ServerScriptService/Script 하위에 배치

local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")

local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"
local HEARTBEAT_INTERVAL = 120  -- 2분

local activeSessions = {}

local PlaywaveService = {}

local function apiRequest(method, path, body)
    local requestInfo = {
        Url = API_URL .. path,
        Method = method,
        Headers = {
            ["X-Api-Key"] = API_KEY,
            ["Content-Type"] = "application/json",
        },
    }
    if body then
        requestInfo.Body = HttpService:JSONEncode(body)
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync(requestInfo)
    end)

    if not success then
        warn("[Playwave] HTTP failed:", response)
        return nil, "NETWORK_ERROR"
    end

    if response.StatusCode >= 400 then
        local errorBody = nil
        pcall(function() errorBody = HttpService:JSONDecode(response.Body) end)
        return errorBody, "HTTP_" .. tostring(response.StatusCode)
    end

    local ok, decoded = pcall(function() return HttpService:JSONDecode(response.Body) end)
    if not ok then
        warn("[Playwave] JSON decode failed")
        return nil, "DECODE_ERROR"
    end
    return decoded, nil
end

local function startHeartbeatLoop(userId, gameSessionId, providerUserId)
    return task.spawn(function()
        while activeSessions[userId] do
            task.wait(HEARTBEAT_INTERVAL)
            if not activeSessions[userId] then break end

            local result, err = apiRequest("PATCH", "/game/session/heartbeat", {
                game_session_id = gameSessionId,
                provider_user_id = providerUserId,
            })

            if err then
                if err == "HTTP_404" or err == "HTTP_410" or string.sub(err, 1, 7) == "HTTP_5" then
                    warn("[Playwave] Session lost for UserId", userId, ":", err)
                    activeSessions[userId] = nil
                    local player = Players:GetPlayerByUserId(userId)
                    if player then player:Kick("PlayWave session expired") end
                    break
                end
                warn("[Playwave] Heartbeat error:", err)
                continue
            end

            if result and result.data and result.data.result == "CHARGE_EXHAUSTED" then
                local player = Players:GetPlayerByUserId(userId)
                if player then warn("[Playwave] Charge exhausted for", player.Name) end
            end
        end
    end)
end

local function endPlayerSession(userId)
    local session = activeSessions[userId]
    if not session then return end
    activeSessions[userId] = nil
    if session.heartbeatThread then task.cancel(session.heartbeatThread) end

    local clientPlayDuration = math.floor(os.clock() - session.startTime)
    local _, err = apiRequest("DELETE", "/game/session/end", {
        game_session_id = session.gameSessionId,
        play_duration_sec = clientPlayDuration,
    })
    if err and err ~= "HTTP_404" and err ~= "HTTP_409" then
        warn("[Playwave] End session failed for UserId", userId, ":", err)
    end
end

function PlaywaveService.init(clientReadyEvent, onPcCafe)
    clientReadyEvent.OnServerEvent:Connect(function(player)
        local joinData = player:GetJoinData()
        local ott = joinData.LaunchData

        if not ott or ott == "" then
            return
        end

        local result, err = apiRequest("POST", "/game/session/verify", {
            ott = ott,
            provider_user_id = tostring(player.UserId),
        })

        if err then
            warn("[Playwave] Verify error:", err)
            return
        end

        if result and result.success and result.data and result.data.is_valid then
            local data = result.data

            activeSessions[player.UserId] = {
                gameSessionId = data.game_session_id,
                startTime = os.clock(),
                heartbeatThread = nil,
            }
            activeSessions[player.UserId].heartbeatThread = startHeartbeatLoop(
                player.UserId,
                data.game_session_id,
                tostring(player.UserId)
            )

            if data.is_pc_cafe and onPcCafe then
                onPcCafe(player)
            end
        else
            if result and result.data then
                warn("[Playwave] Verify failed:", player.Name, result.data.reason)
            end
        end
    end)

    Players.PlayerRemoving:Connect(function(player)
        endPlayerSession(player.UserId)
    end)

    game:BindToClose(function()
        for userId in pairs(activeSessions) do
            endPlayerSession(userId)
        end
    end)
end

return PlaywaveService

서버 스크립트 (Script)

PlaywaveService를 초기화하고 게임 로직과 연결합니다.
-- Script (ServerScriptService)
local PlaywaveService = require(script.PlaywaveService)
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- RemoteEvents 생성
local launchDataEvent = Instance.new("RemoteEvent")
launchDataEvent.Name = "LaunchDataEvent"
launchDataEvent.Parent = ReplicatedStorage

local clientReadyEvent = Instance.new("RemoteEvent")
clientReadyEvent.Name = "ClientReadyEvent"
clientReadyEvent.Parent = ReplicatedStorage

local respawnEvent = Instance.new("RemoteEvent")
respawnEvent.Name = "RespawnEvent"
respawnEvent.Parent = ReplicatedStorage

respawnEvent.OnServerEvent:Connect(function(player)
    player:LoadCharacterAsync()
end)

-- PlayWave 초기화
PlaywaveService.init(clientReadyEvent, function(player)
    launchDataEvent:FireClient(player, "PC_CAFE")
end)

클라이언트 스크립트 (LocalScript)

-- Client (StarterPlayerScripts/LocalScript)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RedeemUI = require(script.RedeemUI)

local redeem = RedeemUI.new()
redeem:start()

-- 서버에 준비 완료 신호 전송
local clientReadyEvent = ReplicatedStorage:WaitForChild("ClientReadyEvent")
clientReadyEvent:FireServer()

RedeemUI 모듈 (선택)

PC방 인증 상태를 화면 오른쪽 상단에 표시하는 UI 모듈입니다.
-- RedeemUI (ModuleScript)
-- StarterPlayerScripts/Client 하위에 배치

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")

local RedeemUI = {}
RedeemUI.__index = RedeemUI

function RedeemUI.new()
    local self = setmetatable({}, RedeemUI)
    self._player = Players.LocalPlayer
    return self
end

function RedeemUI:_build()
    local playerGui = self._player.PlayerGui

    local screenGui = Instance.new("ScreenGui")
    screenGui.Name = "RedeemUI"
    screenGui.ResetOnSpawn = false
    screenGui.IgnoreGuiInset = true
    screenGui.Parent = playerGui

    local frame = Instance.new("Frame")
    frame.Size = UDim2.fromOffset(220, 80)
    frame.Position = UDim2.new(1, 20, 0, 16)
    frame.AnchorPoint = Vector2.new(1, 0)
    frame.BackgroundColor3 = Color3.fromRGB(15, 10, 30)
    frame.BackgroundTransparency = 0.5
    frame.BorderSizePixel = 0
    frame.Parent = screenGui

    local corner = Instance.new("UICorner")
    corner.CornerRadius = UDim.new(0, 12)
    corner.Parent = frame

    local label = Instance.new("TextLabel")
    label.Size = UDim2.new(1, -16, 0.45, 0)
    label.Position = UDim2.fromOffset(10, 8)
    label.BackgroundTransparency = 1
    label.Text = "REDEEM CODE"
    label.TextColor3 = Color3.fromRGB(255, 255, 255)
    label.TextXAlignment = Enum.TextXAlignment.Left
    label.Font = Enum.Font.GothamBold
    label.TextSize = 13
    label.Parent = frame

    local codeLabel = Instance.new("TextLabel")
    codeLabel.Name = "CodeLabel"
    codeLabel.Size = UDim2.new(1, -16, 0.3, 0)
    codeLabel.Position = UDim2.new(0, 10, 0.45, 0)
    codeLabel.BackgroundTransparency = 1
    codeLabel.Text = "INACTIVE"
    codeLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
    codeLabel.TextXAlignment = Enum.TextXAlignment.Left
    codeLabel.Font = Enum.Font.GothamBold
    codeLabel.TextSize = 16
    codeLabel.Parent = frame

    local userLabel = Instance.new("TextLabel")
    userLabel.Size = UDim2.new(1, -16, 0.25, 0)
    userLabel.Position = UDim2.new(0, 10, 0.72, 0)
    userLabel.BackgroundTransparency = 1
    userLabel.Text = "ID: " .. tostring(self._player.UserId)
    userLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
    userLabel.TextXAlignment = Enum.TextXAlignment.Left
    userLabel.Font = Enum.Font.Gotham
    userLabel.TextSize = 13
    userLabel.Parent = frame

    TweenService:Create(frame, TweenInfo.new(0.4, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
        Position = UDim2.new(1, -16, 0, 16),
    }):Play()

    return frame, label, codeLabel
end

function RedeemUI:start()
    local frame, label, codeLabel = self:_build()

    local event = ReplicatedStorage:WaitForChild("LaunchDataEvent", 30)
    if not event then return end

    event.OnClientEvent:Connect(function(code)
        label.TextColor3 = Color3.fromRGB(0, 255, 200)
        codeLabel.Text = code
        codeLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
    end)
end

return RedeemUI

동작 요약

1

클라이언트 준비

LocalScript가 ClientReadyEvent:FireServer()로 서버에 준비 완료 신호를 보냅니다.
2

OTT 검증

PlaywaveService가 GetJoinData().LaunchData에서 OTT를 추출하고 서버에 검증 요청을 보냅니다.
3

세션 활성화

검증 성공 시 게임 세션을 저장하고 2분 간격 하트비트 루프를 시작합니다.
4

혜택 지급

onPcCafe 콜백이 호출되어 게임별 혜택 로직이 실행됩니다.
5

세션 종료

PlayerRemoving 또는 BindToClose에서 세션 종료 API를 호출합니다.