先日、CCKでワールド内課金機能が公開されましたね!
ワールドクリエイターにとっては、ものすごーーく!待望の機能では無いでしょうか?
今回、「ワールド内課金」+「外部通信」機能を組み合わせて、ワールド内課金者ランキング機能を作成しました。
上記の例は、実際に課金者の名前がランキングボードに掲載されたときのものですが、見たとき感動したものです!
ワールド内課金者ランキング機能は、もしかしたら「お金稼ぎしたいの?」と賛否両論を呼ぶ機能かもしれません。
しかし、筆者は、課金してくれるユーザーに対して、感謝の気持ちを示す1つの手段と考えています。
この記事では、私が取り組んだ、「ワールド内課金者ランキング機能」の実装過程について解説します。
前提条件
この記事を読む前に
この記事は、CCK、ワールド内課金、外部通信機能についてある程度の知識がある人向けに書いています。
この記事を読む前、以下の記事を読んでおくと、さらに理解しやすいと思います!
8/29のCluster Conference2024で発表された「ワールド内課金」機能。8/29にClust…
Cluster Creator Kitで利用できる機能として、新しく「テキスト入出力」「外部通信」の機能が正式…
サンプルコードの利用について
サンプルコードは、自由に使ってOKです。
ただし、利用することによって何かしら損害が発生しても、筆者は責任を負わないものとします。
また、サンプルコードは、通信エラーなどの考慮がありません。よって、通信状況により、課金情報が正しく反映されないことがあります。
開発環境
- Unity 2021.3.4f
- CCK 2.21.0
- GAS(Google Apps Script)
- Googleスプレッドシート(記事の中では、「スプシ」と略す)
機能一覧
作成した機能をざっくりと上げると以下の感じです。
- ワールド内課金ができる(Unity側)
- 課金したユーザ情報を外部通信機能を使ってサーバー(GAS)に送信する(Unity側)
- 課金ユーザの情報の一覧をスプシに保存する(GAS側)
- ユーザーごとの課金額合計(購入したcoin数)がわかる(集計結果をスプシの「結果」シートに乗せる)(GAS側)
- 集計結果をランキングデータとしてUnity側に返す(GAS側)
- 受け取った集計結果をTextViewに表示する(Unity側)
実装方法は?
この章では、「ワールド内課金者ランキング機能」の実装例を示していきます。
詳細は、実装例のコードに書いたので、そちらを参照してください。
うっかり課金で、coinがなくなってしまった絵(笑)
■もしうっかり通常ワールドで、テスト中の商品を課金してしまったら?
もし、テスト中の商品をうっかり課金してしまったら、clusterの「お問い合わせ窓口」に問い合わせれば良いとのことです!
Unity側の実装例
やっていることは、ざっくりいうと以下の通り。
- ワールド内課金ができる
- 課金発生時、ユーザ情報を外部通信機能を使ってサーバー(GAS)に送信する
- サーバー受け取った集計結果をTextViewに表示する(Unity側)
この例では、サーバに情報を送れなかったときのリトライ処理が無いので、必要に応じて実装が必要かもしれません。
//参考資料
//https://creator.cluster.mu/2024/08/29/purchase-supportbox/
//課金用のID
const productId = "cluster公式Webサイトのワールド編集ページで設定した商品のID";
//ワールドID
const worldId = "あなたのワールドIDを入力しよう";
//価値(coin数など)
const billingAmountValue = 100;
//取得するランキング数
const top = 3;
// 名前と点数(Amount)を表示するText View
const names = [$.subNode("Name_1"), $.subNode("Name_2"), $.subNode("Name_3")];
const scores = [$.subNode("Score_1"), $.subNode("Score_2"), $.subNode("Score_3")];
$.onStart(() => {
// 購入通知を購読する
$.subscribePurchase(productId);
//現在の課金ランキングを取得する
let request = {type: "ranking", records: [], top: top};
//サーバーに送信する
$.callExternal(JSON.stringify(request), "ranking_only");
$.log("onStart end");
});
$.onUpdate(deltaTime => {
// 10秒に1回、定期的にスペース内にいるすべてのプレイヤーの購入状況を確認する
let timer = $.state.timer ?? 0;
timer -= deltaTime;
if (timer <= 0) {
timer += 10;
//スペースに居るユーザの情報を取得する
const allPlayers = $.getPlayersNear(new Vector3(), Infinity);
$.getOwnProducts(productId, allPlayers, "");
}
$.state.timer = timer;
});
$.onInteract(player => {
//課金リクエスト
$.log("onInteract player");
$.log(player.userDisplayName);
player.requestPurchase(productId, "");
});
$.onPurchaseUpdated((player, productId) => {
// 商品が購入された場合、スペース内にいるすべてのプレイヤーの購入状況を確認する
const allPlayers = $.getPlayersNear(new Vector3(), Infinity);
//https://docs.cluster.mu/script/interfaces/ClusterScript.html#getOwnProducts
$.getOwnProducts(productId, allPlayers, "");
});
$.onGetOwnProducts((ownProducts, meta, errorReason) => {
// 商品の所持状況を取得したとき
let total = 0;
for (let ownProduct of ownProducts) {
// スペース内での商品所持数の合計を計算
total += ownProduct.plusAmount - ownProduct.minusAmount;
}
// スペース内での商品所持数の合計数を表示
$.subNode("Text").setText("みんなのサポート数: " + total);
});
$.onRequestPurchaseStatus((meta, status, errorReason, player) => {
if (status == PurchaseRequestStatus.Purchased) {
// Play Audio Source Gimmickがついたアイテムにシグナルを送る
$.log("onRequestPurchaseStatus player");
$.log(player.userDisplayName);
$.sendSignalCompat("this", "SupportSE");
//課金ユーザの情報をサーバーに送信する
let records = [];
//ユーザの情報、課金情報
record = {name: player.userDisplayName, idfc:player.idfc, userId:player.userId, worldId:worldId, productId:productId, billingAmountValue:billingAmountValue};
records.push(record);
let request = {type: "rankingInsert", records: records, top: top};
//サーバーに送信する
$.callExternal(JSON.stringify(request), "syncRanking");
}
});
// callExternal実行後、サーバーからの応答を受け取ったときに呼び出される処理
$.onExternalCallEnd((response, meta, errorReason) =>
{
// サーバーとの通信でエラーが発生した場合にその理由を表示
if (response == null) {
$.log("callExternal ERROR: " + errorReason);
return;
}
$.log("通信結果が戻ってきた meta:" + meta);
// metaを照合して処理を分岐
// metaの値はcallExternalの第2引数に渡したもの
if (meta === "syncRanking" || meta === "ranking_only") {
$.log("データ着たぞ");
$.log(response);
$.log(errorReason);
// サーバーからのresponseのJSONをパース
let parsedResponse = JSON.parse(response);
// responseの情報でtextViewを更新
for(let i = 0; i < top; i++)
{
names[i].setText(parsedResponse.rankers[i].name);
scores[i].setText(parsedResponse.rankers[i].score);
}
}
});
Unityプロジェクトの例も参考に乗せておきます!
GAS側の実装
GASの実装前の準備として、以下を行います。
- スプレッドシートを新規作成
- シート名を変更します(サンプルコードでは、シート名を「RankingSampleSheet」にしています)
上記の設定についてよくわからない場合は、以下のcluster公式記事を参考にしてください!
Cluster Creator Kitで利用できる機能として、新しく「テキスト入出力」「外部通信」の機能が正式…
GAS側では、以下のような処理をしています。
- 課金ユーザの情報の一覧をスプシに保存する(例では、「RankingSampleSheet」シートに保存しています)
- ユーザーごとの課金額合計(購入したcoin数)がわかる(集計結果をスプシの「結果」シートに乗せる)
- 集計結果をランキングデータとしてUnity側に返す
サンプルコードは以下のとおりです。
//参考資料
//https://creator.cluster.mu/2024/03/01/callexternal-textview/
function doPostTest() {
//GASからの起動用テスト実行用(doPost関数を直接実行できないため)
//テストコード
let e = {
"records": [{
"name": "俺様だよ",
"idfc": "idfc_value",
"userId": "userId_value",
"worldId": "worldId_value",
"productId": "productId_value",
"billingAmountValue": 100,
}],
"type":"rankingInsert",
"top":3,
};
r = rankingMain(e);
Logger.log(r);
}
function doPost(e){
//サーバーサイドから受け取る方
// 受け取ったデータを取得
var params = JSON.parse(e.postData.getDataAsString());
// 受け取ったデータからrequestの内容を取得
var request = JSON.parse(params.request);
return rankingMain(request)
}
function rankingMain(request) {
//ランキングのメイン処理
//課金したタイミング(request.type === "rankingInsert")で、呼ばれる場合は、購入情報もスプシに追加する
// 受け取ったデータを取得
//var params = JSON.parse(e.postData.getDataAsString());
// 受け取ったデータからrequestの内容を取得
//var request = JSON.parse(params.request);
//パラメータの確認
Logger.log("リクエスト内容");
Logger.log(request);
var response = "";
// requestの内容に応じて処理を分岐
if (request.type === "rankingInsert") {
//課金が発生したときのみ実行
//新しいデータを追加する
// データ保存用のスプレッドシートを取得
const file = SpreadsheetApp.getActiveSpreadsheet();
const sheet = file.getSheetByName("RankingSampleSheet");
// スプレッドシートのデータがある一番下の行
var row = sheet.getLastRow();
Logger.log("row:" + row)
if(row == 0){
//ヘッダーを書き込む
row = 1;
sheet.getRange(row, 1).setValue("date");
sheet.getRange(row, 2).setValue("name");
sheet.getRange(row, 3).setValue("idfc");
sheet.getRange(row, 4).setValue("userId");
sheet.getRange(row, 5).setValue("worldId");
sheet.getRange(row, 6).setValue("productId");
sheet.getRange(row, 7).setValue("billingAmountValue");
}
//時間を取り出す
const date = new Date();
// 新しいレコードをスプレッドシートの下に追加
request.records.forEach(function(record){
//var name = record.name;
//var score = record.score;
row += 1;
sheet.getRange(row, 1).setValue(date);
sheet.getRange(row, 2).setValue(record.name);
sheet.getRange(row, 3).setValue(record.idfc);
sheet.getRange(row, 4).setValue(record.userId);
sheet.getRange(row, 5).setValue(record.worldId);
sheet.getRange(row, 6).setValue(record.productId);
sheet.getRange(row, 7).setValue(record.billingAmountValue);
});
}
//ランキング計算をする
var topRankersInSheet = calculateAndSortBillingAmounts();
//topの数までに限定する
var topRankersInSheet = topRankersInSheet.length > request.top ? topRankersInSheet.slice(0, request.top) : topRankersInSheet;
var topRankers = topRankersInSheet.map((ranker) => ({name: ranker[0], score: ranker[2]}));
var data = {rankers: topRankers};
response = JSON.stringify(data);
Logger.log("response:" + response);
// 返信用のデータを作成
var output = ContentService.createTextOutput();
output.setMimeType(ContentService.MimeType.JSON);
// Cluster Creator Kitで発行されたトークン
var token = PropertiesService.getScriptProperties().getProperty('token');
// 返信の内容にstringにしたデータと認証用のトークンを設定
output.setContent(JSON.stringify({ response: response, verify: token }));
return output;
}
function calculateAndSortBillingAmounts() {
//ランキングの集計
// アクティブなスプレッドシートとデータを取得
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('RankingSampleSheet');
var dataRange = sheet.getDataRange();
var data = dataRange.getValues();
// userIdごとのbillingAmountValueの合計とnameを格納するオブジェクト
var userBillingTotals = {};
// データの最初の行はヘッダーなので、2行目から開始
for (var i = 1; i < data.length; i++) {
var name = data[i][1]; // nameは2列目 (インデックス1)
//var userId = data[i][3]; // userIdは4列目 (インデックス3)
var userId = data[i][2]; // idfcは3列目 (インデックス2)
var billingAmountValue = data[i][6]; // billingAmountValueは7列目 (インデックス6)
if (userBillingTotals[userId]) {
userBillingTotals[userId].billingAmountTotal += billingAmountValue; // 既存のuserIdの場合、値を加算
} else {
userBillingTotals[userId] = {
name: name,
billingAmountTotal: billingAmountValue
}; // 新しいuserIdの場合、nameとスコアを初期化
}
}
// 結果を配列に変換し、billingAmountValueの合計で降順ソート
var sortedBillingAmounts = [];
for (var userId in userBillingTotals) {
sortedBillingAmounts.push([userBillingTotals[userId].name, userId, userBillingTotals[userId].billingAmountTotal]);
}
// 合計金額で降順にソート
sortedBillingAmounts.sort(function(a, b) {
return b[2] - a[2];
});
// 結果を新しいシートに出力する
var resultSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('結果');
if (!resultSheet) {
resultSheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet('結果');
} else {
resultSheet.clear(); // 既存のデータをクリア
}
// ヘッダーを追加
resultSheet.appendRow(['name', 'userId', '合計billingAmountValue']);
// ソートされた結果を出力
for (var j = 0; j < sortedBillingAmounts.length; j++) {
resultSheet.appendRow(sortedBillingAmounts[j]);
}
return sortedBillingAmounts;
}
上記の画像は、スプシ(RankingSampleSheetシート)にデータを貯めたときの例。
スクリプトを作成したら、以下の記事の「サーバーの公開と接続先設定」を参考に公開するための設定を行います。
Cluster Creator Kitで利用できる機能として、新しく「テキスト入出力」「外部通信」の機能が正式…
課金者ランキング機能において、実運用で考慮が必要と思われること
課金者ランキング機能を実運用するうえで、いくつか課題があると思います。
個人的には、まずは公開してみて、あとから考慮してもよいかもしれません。
- 課金ランキングに出す項目の精査
- ランキングの集計範囲
- データの保管場所
- レスポンス文字列の考慮
課金ランキングに出す項目の精査
今回の例では、課金者名、順位(並び順で表現)、ポイント(購入コインの合計数)を出してします。
名前と順位だけにするか、それともポイントまで出すか?ワールドの性質によって考慮したほうが良いでしょう。
ランキングの集計範囲
最初はだれも課金しないだろうから、全期間?
課金者が、増えてきたら週単位にしても良いかもしれません。
データが増えたら期間を考慮したほうがよさそう。
データの保管場所
スプシは手軽に実装できるが、ある程度、データが溜まると管理が大変です。
あるタイミング、データベース(DB)に置き換えたほうが良いでしょう。
レスポンス文字数の考慮
clusterの仕様では、1リクエストに対して、受信できるbyte数が1Kという制限があります。1Kは、半角文字なら1024文字です。
これが、全角・日本語(UTF-8)を使うとなると、1文字あたり3~8byteらしいので、日本語だけだと150文字くらいになってしまう。ユーザー名が長いときは、サーバー側で、ある文字数で切る取る処理が必要かもしれません。
まとめ
今回は、ワールド内課金者ランキング機能の実装例など紹介しました。
ワールド内課金者ランキング機能は、「お金稼ぎしたいの?」と批判が出るかもしれません。
しかし、課金してくれるユーザーに対して、感謝の気持ちを示す手段の1つと考えています。
また、課金が発生することにより、クリエイターが持続的に活動できるようになります。
収入が得られることで、クリエイターはより多くの時間とリソースをコンテンツの制作や改善に充てることができ、結果としてより質の高い作品が生まれると考えます。
もし記事がお役に立ったら幸いです。