Skip to content

Instantly share code, notes, and snippets.

@kitasuke kitasuke/AppWarp.md Secret
Last active Aug 29, 2015

Embed
What would you like to do?
初めてのAppWarp

AppWarp導入

参考資料
ドキュメント
APIレファレンス
実装事例
2014/5/16時点

概要

AppWarpはリアルタイム通信を行うマルチプレイヤーゲームのためクラウドプラットフォームです。 使用するにはアプリ用APIキーを取得する必要があります。
AppWarpでは主に、ルームとロビーという概念があります。 ルームへの入出、アクティビティ通知の受け取り、チャットメッセージの送信、プレイ情報の送受信など、様々な用途に対応したAPIが用意されています。
AppWarpを使用することで、簡単にリアルタイム通信のマルチプレイヤーゲームが作れます。

サーバーとの通信

事前準備として、AppWarpサーバーとの下記を用いて認証処理を行います。

  1. APIキーとシークレットキーの使用
    2.ユニークなuserIDの発行
    *もし複数のクライアントが同じユーザーIDを使用しても、既存のユーザーIDしか有効になりません。

ルーム

ルームへの入出、退出、通知登録、通知登録解除、ルームプロパティの編集などのAPIが用意されています。
ユーザーは同時に複数のルームには存在出来ません。
もし新しいルームに入出すると、自動的に古いルームから新しいルームへの移動とみなされます。
ルームには3つのプロパティが必要です。

  • 最大参加可能人数
  • ルーム名
  • ルームのオーナー

ルームの種類

  • 静的なルーム
    管理画面から作成可能です。管理画面以外からは、ルームが削除されることはありません。 管理画面で作成したルームでも、SDKのAPIを通してルームプロパティの編集は可能です。
  • 動的なルーム
    SDKのAPIを通して作成されるルームです。 入出しているユーザー数が0人になか、ルーム作成直後60秒間誰も入出しないと、自動的にルームは削除されます。 こちらもAPIを通してルームプロパティの編集やルームの削除が可能です。

ユーザーがルームに入出したあとは、APIを通して文字ベースのチャットメッセージの交換などが行えます。 ルームのアクティビティ通知を登録している全てのユーザーに対して、ブロードキャストでメッセージを送信することも可能です。

  • ターン制度のルーム 一定時間のターンごとにプレイできるルームです。 制限時間を超えると、自動的にユーザーのターンが変わります。 自分のターンの時だけ、動作が可能です。

*入出とアクティビティ通知登録の違い 入出後は、他プレイヤーとチャットなどで交流が可能になります。 他プレイヤーからのアクティビティに対する通知を受け取るには、ルームに対してアクティビティ通知の登録が必要になります。

ロビー

ロビーは自動的に作成される特別なルームのような概念です。ゲームのようのルーム内で他プレイヤーとプレイしていなくても、チャットなどで交流が可能な空間です。 ルームに入出するまでなどに、ロビーで待機が可能です。 ロビーに入出すると、ロビー内のアクティビティ通知だけでなく、すべてのルームのアクティビティ通知を受けることができます。 ロビーとルームの両方への入出は出来ず、最後に入出した方が適用されます。

リクエスト・レスポンス・通知

AppWarpのクライアントSDKは非同期のリクエスト・レスポンス・通知を行います。 全てのAPIに対してサーバーでレスポンスが生成されます。 レスポンスは、AppWarpの該当するレスポンスリスナーに向けて返されます。

料金

従量課金制。 メッセージ数(サーバーとの通信回数)で変動する。 ユーザー数は料金には無関係です。

リアルタイムターン制ゲーム

  • 分散型タイマー管理
  • 分散型ターン管理
  • 分散型状態管理

全ての処理はサーバーサイドで行われています。 プレイヤーの動作や、1ターンの制限時間が過ぎた場合など、クライアントサイドへ通知がいきます。 プレイヤーの順番は、ルームに入出した順で決められます。 デベロッパーは、ターンの制限時間を自由に決められます。 プレイヤーが制限時間内に何も動作をしなかった場合は、次のプレイヤーのターンになります。 タイマーやターン管理に関しては、すべてサーバーサイドで実装されていて、クライアントサイドはAPIを呼ぶだけで実装できます。

ターンの進行順序は固定で、クライアントサイドからの変更は不可能です。 ターンを繰り返す場合や、スキップする場合のためのAPIは用意されていません。 その場合は、チャット・アップデートメッセージやルームプロパティを使用して、デベロッパーが独自に対策をする必要があります。 さらにカスタマイズが必要な場合は、オンプレミスなAppWarpS2が良い解決方法になるかもしれません。

プレイヤーの動作を送信するのは簡単で、500文字までの文字列を送信できます。

ルームマッチングとプロパティ

ルームプロパティ

ルームプロパティはルームに関するキーバリュー形式のデータの集合体です。 ユーザーが特定の目的のためや、何らかの関連データと一緒にルームを作成したい場合、プロパティの集合体をそれらのために活用できます。 例えば、ユーザーのレベル別にルームを作成したい場合は、level => Beginnerのようにプロパティを設定出来ます。 プロパティはルーム作成後も、追加や更新が可能です。

プロパティの更新
updateRoomPropertiesAPIでプロパティの更新が可能です。 その結果は、RoomListeneronUpdatePropertyDoneコールバック内で取得できます。 全てのアクティビティ通知登録済みのユーザーが、この更新をNotificationListenerのonUserChangeRoomPropertyコールバック関数で受け取ることができます。

プロパティのロック

デベロッパーはユーザーに対して、プロパティのロック権限を与えることができます。 ユーザーにロックされている間は、プロパティの更新は不可能になります。 ルーム内にいるユーザーのみがロック可能です。 ロック情報の管理については、ロック済みのプロパティと、ロックしたユーザー名がルーム内のテーブルで管理されます。 これは分散型同期に関する問題を解決する上で、非常に便利に活用できると思います。

ここで、簡単な例を紹介します。
まずは最大ユーザー数が4人のルームがあり、ゲームをプレイするにはユーザーはアバターを選択しなければなりません。 既に選択済みのアバターを、他ユーザーは選択できません。 もし退出したユーザーがいれば、そのユーザーが選んだアバターは選択可能になります。

このような状況で、プロパティのロックを活用できます。

  • 最大4人までのルーム作成
  • 4つのアバターをプロパティとして登録
  • ユーザーがルームに入出して、アバターリストの表示
  • ユーザーが選択したアバターをロックする
  • ユーザーが退出した際は、ロックしたアバターを選択可能にする

lockPropertiesunlockPropertiesでロック、またはアンロックを行います。

プロパティに基づくマッチング

getRoomWithPropertiesjoinRoomWithPropertiesを利用して、プロパティをベースとしたルームマッチングが出来ます。

ルーム内のユーザー数に基づくマッチング

ランダムに集まったユーザーとクイックプレイなどのために、ユーザー数を考慮したマッチングが可能になっています。 例えば以下のフローで簡単なマッチングが可能です。

  • すべてのルームを取得する
  • すべてのルーム情報を1つずつ取得する
  • すべてのルームに対して、定員に空きがあるか確認する
  • 空きがあるルームを見つけたら、入出する

joinRoomInRangegetRoomInRangeでユーザー数の幅を指定して、入出やルームの取得が出来ます。

App42 Backend APIs

リアルタイムゲーム用のAPI提供では、ユーザー管理、リーダーボード、報酬付与、JSONストレージ、プッシュ通知なども、App42 cloudがAPIを提供しています。 AppWarp単独や、AppWarpとApp42 cloudの組み合わせのどちらでも可能になっています。 両方使用する場合は、アプリキーは両用出来ます。

通信遅延対策

背景

モバイルデバイスにおいて、データ通信は常に問題となっています。 ユーザーが出先の場合、データソースは3Gになったり4Gになったりと頻繁に変動します。 この現象が、永続的なデータ通信に頼っているアプリに大きなインパクトを与えます。

AppWarpのバイナリープロトコルはTCP上で走ります。 これが、早くて、効率が良い、信頼できるリアルタイム通信を可能にしています。 しかしモバイルデバイスでは、IPアドレスが外出時に頻繁に変わってしまうのが問題になります。 これが下位の通信に影響を及ぼすことになり、アプリ側は先ほどまで居たルームに対して、再接続、再入出、再通知登録をして状態を復帰させる必要があります。 もっと最悪な状況は、通信が不安定になる度に、ユーザーが退出したと通知が流れてしまうことです。 これはゲームロジックの観点からも、非常に大きな問題になるでしょう。

AppWarpの通信強度

上記の問題を解決するために、AppWarpは強度な通信機能を提供しています。 通信エラーが起こった際は、AppWarpSDKはそのエラーがリカバリー可能かどうかを教えてくれます。 もしリカバリー可能なら、リカバリーAPIが通信状態が正常になった際に実行されて、自動的にユーザーの直前の状態をリストアしてくれるます。これにより、ルームへの再入出や再通知登録などの状態管理は、全くする必要がないことになります。 それに加えて、一時的なエラーが起きた際は、AppWarpが他のユーザーに向けて、該当するユーザーがポーズ状態であると知らせてくれます。 一定時間待っても通信エラーが直らないときは、該当ユーザーは退出処理の処理が実行されます。 タイムアウト前に該当ユーザーの状態が正常に戻ると、ポーズが解消されてプレイが再開します。

//
// ChatScene.cpp
// AppWarp
//
// Created by Yusuke Kita on 5/15/14.
// Copyright (c) 2014 Yusuke Kita. All rights reserved.
//
#include "ChatScene.h"
USING_NS_CC;
ChatScene::ChatScene()
{
_winSize = CCDirector::sharedDirector()->getVisibleSize();
}
ChatScene::~ChatScene()
{
CC_SAFE_RELEASE(_buttons);
CC_SAFE_RELEASE(_messages);
}
CCScene* ChatScene::scene()
{
// 'scene' is an autorelease object
CCScene *scene = CCScene::create();
// 'layer' is an autorelease object
ChatScene *layer = ChatScene::create();
// add layer as a child to scene
scene->addChild(layer);
// return the scene
return scene;
}
// on "init" you need to initialize your instance
bool ChatScene::init()
{
//////////////////////////////
// 1. super init first
if ( !CCLayer::init() )
{
return false;
}
// initialize messages
_messages = CCArray::create();
_messages->retain();
_messages->addObject(CCString::create("Morning"));
_messages->addObject(CCString::create("Hello"));
_messages->addObject(CCString::create("Nice!"));
_messages->addObject(CCString::create("Great!"));
_messages->addObject(CCString::create("Awesome!"));
_messages->addObject(CCString::create("Congrats"));
_messages->addObject(CCString::create("Good job"));
_messages->addObject(CCString::create("Bye"));
// initialize message buttons
_buttons = CCArray::create();
_buttons->retain();
// create a connecting label
_connectingLabel = CCLabelTTF::create("Connecting ...", "Arial", 50.f);
_connectingLabel->setPosition(ccp(_winSize.width/2, _winSize.height/2));
this->addChild(_connectingLabel);
// set touch mode
this->setTouchEnabled(true);
this->setTouchMode(kCCTouchesOneByOne);
// connect to AppWarp
this->connectToAppWarp();
return true;
}
/*************** Message Handler ******************/
void ChatScene::showMessages()
{
// remove the connecting label
this->removeChild(_connectingLabel);
// show user name
CCLabelTTF *userLabel = CCLabelTTF::create("Your userID", "Arial", 46);
userLabel->setPosition(ccp(_winSize.width*1/5, _winSize.height*9/10));
this->addChild(userLabel);
_userIDLabel = CCLabelTTF::create(_userName.c_str(), "Arial", 46);
_userIDLabel->setPosition(ccp(_winSize.width*1/5, _winSize.height*8/10));
this->addChild(_userIDLabel);
// show opponent name
CCLabelTTF *opponentLabel = CCLabelTTF::create("Opponent's userID", "Arial", 46);
opponentLabel->setPosition(ccp(_winSize.width*4/5, _winSize.height*9/10));
this->addChild(opponentLabel);
_opponentIDLabel = CCLabelTTF::create("", "Arial", 46);
_opponentIDLabel->setPosition(ccp(_winSize.width*4/5, _winSize.height*8/10));
this->addChild(_opponentIDLabel);
// set each messages
int i;
int row;
int count = _messages->count();
for (i = 0; i < count; i++) {
// two rows
row = i / 4;
// set message buttons
CCSprite *button = CCSprite::create("button.png");
button->setScaleX(0.6f);
button->setPosition(ccp(_winSize.width * (i % 4)/4, _winSize.height * row/4));
button->setAnchorPoint(ccp(0, 0));
this->addChild(button);
// set message texts
CCString *string = (CCString *)_messages->objectAtIndex(i);
CCLabelTTF *label = CCLabelTTF::create(string->getCString(), "Arial", 46);
label->setHorizontalAlignment(kCCTextAlignmentCenter);
label->setVerticalAlignment(kCCVerticalTextAlignmentCenter);
label->setAnchorPoint(ccp(0.5, 0.5));
label->setPosition(ccp(button->getContentSize().width/2, button->getContentSize().height/2));
button->addChild(label);
// add buttons
_buttons->addObject(button);
}
}
void ChatScene::showReceivedMessage(const char *message)
{
// init received message label
CCLabelTTF *label = CCLabelTTF::create();
label->setFontSize(64.f);
label->setPosition(ccp(_winSize.width/2, _winSize.height/2));
label->setScale(0);
label->setString(message);
this->addChild(label);
// animations
CCScaleTo *actionScale = CCScaleTo::create(0.5f, 1.f);
CCMoveTo *actionMove = CCMoveTo::create(2.0f, ccp(_winSize.width/2, _winSize.height));
CCSpawn *actions = CCSpawn::createWithTwoActions(actionScale, actionMove);
// callback method to remove the message
CCCallFuncN *actionDone = CCCallFuncN::create(this, callfuncN_selector(ChatScene::removeMessage));
label->runAction(CCSequence::create(actions, actionDone, NULL));
}
void ChatScene::removeMessage(CCNode *sender)
{
// get a stopped label
CCLabelTTF *label = (CCLabelTTF *)sender;
// remove the label
this->removeChild(label);
}
/***************** Touch Handler ********************/
bool ChatScene::ccTouchBegan(CCTouch *pTouch, CCEvent *pEvent)
{
return true;
}
void ChatScene::ccTouchEnded(CCTouch *pTouch, CCEvent *pEvent)
{
// get a touched point
CCPoint location = CCDirector::sharedDirector()->convertToGL(pTouch->getLocationInView());
// check if the button is tapped
CCObject *obj = NULL;
CCARRAY_FOREACH(_buttons, obj) {
CCSprite *button = (CCSprite *)obj;
if (button->boundingBox().containsPoint(location)) {
// send message
CCArray *children = button->getChildren();
CCLabelTTF *label = (CCLabelTTF *)children->objectAtIndex(0);
this->sendMessage(label->getString());
}
}
}
/**************** AppWarp Helper ****************/
std::string genRandom()
{
std::string charStr;
srand (time(NULL));
for (int i = 0; i < 10; ++i) {
charStr += (char)(65+(rand() % (26)));
}
return charStr;
}
void ChatScene::connectToAppWarp()
{
// get instance
AppWarp::Client *warpClientRef;
// initialize with app key and secret key
AppWarp::Client::initialize(APPWARP_APP_KEY, APPWARP_SECRET_KEY);
// set up each listeners
warpClientRef = AppWarp::Client::getInstance();
warpClientRef->setConnectionRequestListener(this);
warpClientRef->setNotificationListener(this);
warpClientRef->setRoomRequestListener(this);
warpClientRef->setZoneRequestListener(this);
// connect with the user name
_userName = genRandom();
warpClientRef->setRecoveryAllowance(120); // recommended max value is 120 which means 2 minutes
warpClientRef->connect(_userName);
}
/***************** AppWarp callback methods ****************/
void ChatScene::onConnectDone(int res)
{
// check connection state
switch (res) {
case AppWarp::ResultCode::success:
printf("\nonConnectDone .. SUCCESS .. session=%d\n", AppWarp::AppWarpSessionID);
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
warpClientRef->getAllRooms();
break;
case AppWarp::ResultCode::success_recovered:
printf("\nonConnectDone .. SUCCESS with success_recovered .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::connection_error_recoverable:
printf("\nonConnectDone .. FAILED .. connection_error_recoverable .. session=%d\n", AppWarp::AppWarpSessionID);
this->scheduleRecover();
break;
case AppWarp::ResultCode::bad_request:
printf("\nonConnectDone .. FAILED .. bad_request .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::auth_error:
printf("\nonConnectDone .. FAILED .. auth_error .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::resource_not_found:
printf("\nonConnectDone .. FAILED .. resource_not_found .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::resource_moved:
printf("\nonConnectDone .. FAILED .. resource_moved .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::connection_error:
printf("\nonConnectDone .. FAILED .. connection_error .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::size_error:
printf("\nonConnectDone .. FAILED .. size_error .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
case AppWarp::ResultCode::api_not_found:
printf("\nonConnectDone .. FAILED .. api_not_found .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
default:
printf("\nonConnectDone .. FAILED .. unknown reason .. session=%d\n", AppWarp::AppWarpSessionID);
this->unscheduleRecover();
break;
}
return;
}
void ChatScene::onDisconnectDone(int res)
{
printf("\nonDisconnectDone\n");
}
void ChatScene::onCreateRoomDone(AppWarp::room event)
{
std::string roomID = event.roomId;
// join into the room
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
warpClientRef->joinRoom(roomID);
}
void ChatScene::onJoinRoomDone(AppWarp::room revent)
{
if (revent.result == 0) {
printf("\nonJoinRoomDone .. SUCCESS\n");
// subscribe room with ID
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
warpClientRef->subscribeRoom(revent.roomId);
} else {
printf("\nonJoinRoomDone .. FAILED\n");
}
}
void ChatScene::onSubscribeRoomDone(AppWarp::room revent)
{
// check room subscription result
if (revent.result == 0) {
printf("\nonSubscribeRoomDone .. SUCCESS\n");
// show messages
this->showMessages();
// get live room info
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
warpClientRef->getLiveRoomInfo(revent.roomId);
} else {
printf("\nonSubscribeRoomDone .. FAILED\n");
}
}
void ChatScene::onGetLiveRoomInfoDone(AppWarp::liveroom event)
{
int numUser = event.users.size();
// if there is joined user, set as an opponent
if (numUser > 0) {
// if there isn't opponent name yet, get and display it
if (strlen(_opponentIDLabel->getString()) == 0 && _userName != event.users[0]) {
_opponentIDLabel->setString(event.users[0].c_str());
}
}
}
void ChatScene::sendMessage(std::string message)
{
// get AppWarp instance
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
// send string
warpClientRef->sendChat(message);
}
void ChatScene::onChatReceived(AppWarp::chat chatevent)
{
printf("\nonChatReveived: %s\n", chatevent.chat.c_str());
if (chatevent.sender != _userName) {
printf("\n %s says: %s\n", chatevent.sender.c_str(), chatevent.chat.c_str());
}
// show sent message label
this->showReceivedMessage(chatevent.chat.c_str());
}
void ChatScene::scheduleRecover()
{
this->schedule(schedule_selector(ChatScene::recover), 5.0f);
printf("\nReconnecting ...\n");
}
void ChatScene::unscheduleRecover()
{
this->unschedule(schedule_selector(ChatScene::recover));
}
void ChatScene::recover()
{
printf("\nRecovering ...\n");
AppWarp::Client::getInstance()->recoverConnection();
}
void ChatScene::disconnect()
{
printf("\nDisconnecting ...\n");
AppWarp::Client::getInstance()->disconnect();
}
void ChatScene::onGetAllRoomsDone(AppWarp::liveresult event)
{
int count = event.list.size();
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
if (count > 0) {
// search room that contains one user
warpClientRef->getRoomsInUserRange(1, 1);
} else {
// create a turn room
std::map<std::string, std::string> tableProperties;
warpClientRef->createTurnRoom("turnRoom", _userName, 2, tableProperties, 30); // 30 seconds for each turn
}
}
void ChatScene::onGetMatchedRoomsDone(AppWarp::matchedroom event)
{
// a total number of rooms that contain one user
int count = event.roomData.size();
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
if (count == 0) {
std::map<std::string, std::string> tableProperties;
warpClientRef->createTurnRoom("turnRoom", _userName, 2, tableProperties, 30);
} else {
// join into existing room
warpClientRef->joinRoom(event.roomData[0].roomId);
}
}
void ChatScene::onUserJoinedRoom(AppWarp::room event, std::string username)
{
// show opponent username
_opponentIDLabel->setString(username.c_str());
}
void ChatScene::onUserLeftRoom(AppWarp::room event, std::string username)
{
AppWarp::Client *warpClientRef;
warpClientRef = AppWarp::Client::getInstance();
warpClientRef->leaveRoom(event.roomId);
}
void ChatScene::onDeleteRoomDone(AppWarp::room event)
{
}
//
// ChatScene.h
// AppWarp
//
// Created by Yusuke Kita on 5/15/14.
// Copyright (c) 2014 Yusuke Kita. All rights reserved.
//
#ifndef __CHAT_SCENE_H__
#define __CHAT_SCENE_H__
#include "cocos2d.h"
#include "appwarp.h"
USING_NS_CC;
#define APPWARP_APP_KEY "APPWARP_APP_KEY" // please replace with your APP_KEY if your own account is created
#define APPWARP_SECRET_KEY "APPWARP_SECRET_KEY" // please replace with your SECRET_KEY
class ChatScene : public CCLayer, public AppWarp::ConnectionRequestListener, public AppWarp::RoomRequestListener, public AppWarp::NotificationListener, public AppWarp::ZoneRequestListener
{
private:
CCSize _winSize;
CCLabelTTF *_connectingLabel;
CCLabelTTF *_userIDLabel;
CCLabelTTF *_opponentIDLabel;
CCArray *_buttons;
CCArray *_messages;
/******* Message Handler ********/
void showMessages();
void showReceivedMessage(const char *message);
void removeMessage(CCNode *sender);
public:
// Here's a difference. Method 'init' in cocos2d-x returns bool, instead of returning 'id' in cocos2d-iphone
virtual bool init();
// there's no 'id' in cpp, so we recommend returning the class instance pointer
static CCScene* scene();
// implement the "static node()" method manually
CREATE_FUNC(ChatScene);
ChatScene();
~ChatScene();
/*********** AppWarp Helper **************/
std::string _userName;
void connectToAppWarp();
void onConnectDone(int res);
void onDisconnectDone(int res);
void onJoinRoomDone(AppWarp::room revent);
void onSubscribeRoomDone(AppWarp::room revent);
void sendMessage(std::string message);
void onChatReceived(AppWarp::chat chatevent);
void scheduleRecover();
void unscheduleRecover();
void recover();
void disconnect();
void onGetAllRoomsDone(AppWarp::liveresult event);
void onDeleteRoomDone(AppWarp::room event);
void onCreateRoomDone(AppWarp::room event);
void onGetMatchedRoomsDone(AppWarp::matchedroom event);
void onUserJoinedRoom(AppWarp::room event, std::string username);
void onGetLiveRoomInfoDone(AppWarp::liveroom event);
void onUserLeftRoom(AppWarp::room event, std::string username);
/********** Touch Handler **********/
virtual bool ccTouchBegan(CCTouch *pTouch, CCEvent *pEvent);
virtual void ccTouchEnded(CCTouch *pTouch, CCEvent *pEvent);
};
#endif // __CHAT_SCENE_H__
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.