ほんとにあったスキーマ
「ソーシャルゲーム」
大事な連絡
ブログを書くまでがYAPC
アジェンダ
- そもそもスキーマって?
- 定義
- 誰が使うか
- 何をしたいか
- コンテキスト「ソーシャルゲーム」
- 代表的なスキーマ
- ユーザ、カード、称号
- 応用例
- バトル
- トーナメントバトル
- まとめ
そもそもスキーマって?
- スキーマとは
- 誰が使うか
- 何をしたいか
定義
スキーマとは、DBにどうデータが入るかの定義
誰が使うかによって
スキーマの抽象度が変わる
DBが、使う場合
CREATE TABLE user (
name VARCHAR(255) NOT NULL COMMENT '名前',
age INTEGER NOT NULL COMMENT '年齢',
PRIMARY KEY (name)
) COMMENT 'ユーザ';
- DBが、データを取り出しやすい形を指定する
- 属性値の型
- インデックス
人が、使う場合
ユーザを、ユーザたらしめるのは、名前
ユーザは、年齢を持ってる
- 人が、扱いたいことを定義する
- 人によって解釈違うのは、x
Perlが、使う場合
package MyApp::Schema;
use strict;
use warning;
use Teng::Schema::Declare;
table {
name "user";
pk "name";
columns qw( name age );
inflate 'age' => sub { };
deflate 'age' => sub { };
row_class 'MyApp::Row'; # optional
};
- アプリケーションを弄るための工夫がしやすい
- Row object
- inflate/deflate
- スキーマを物理スキーマと論理スキーマに分けた
何をしたいかによって
スキーマが変わる
(例) 「誕生日占いがしたい!」
CREATE TABLE user (
name VARCHAR(255) NOT NULL COMMENT '名前',
birthday DATETIME NOT NULL COMMENT '誕生日',
PRIMARY KEY (name)
) COMMENT 'ユーザ';
- "今日"が分かれば、年齢を計算できる
- ユーザは年齢を持つことは変わらない
- さっきのスキーマと属性含め意味は変わらない
(例) 「パフォーマンスを良くしたい!」
SELECT * FROM name WHERE name = '佐藤健太';
- "佐藤"だと名前のかぶり多くて、インデックス効率がわるそう
- [参考] http://perl-users.jp/articles/advent-calendar/2011/hacker/11
- (この場合は、PRIMARY KEYにname使っているので、nameを主キーから外せないか色々考えることになるだろうけど、、)
そもそも"ユーザ"の定義が気持ち悪い?
やりたいことがずれているんじゃないか?
- 「同姓同名の人いるよね」
- (例) わだゆうすけ
ユーザをユーザ足らしめるものは、名前でいいのか?
- わだゆうすけ.1 、わだゆうすけ.2 ? (識別子を加える?)
- 住所?出生時刻?(属性を加えて、一意性を確保?)
- もし「わだ」さんが双子で、同姓同名の申請をしたら?
- メールアドレス?同じメールアドレスを複数人で使う場合は許容?
- 採番?AUTO_INCREMENT?例えば、出生届けなら採番するにも、役所が分散しているので、マルチマスターな状態(特に大昔、他の国の扱いはどうする?)、採番テーブルを用意する?
逆にユーザは名前で決まることを認める
- 「わだゆうすけ」から、ユーザが決まり、
- また、名前のないユーザはいない
- (例) わだゆうすけ <-> yusukebe 別ユーザ
コンテキスト
「ソーシャルゲーム」
ゲームとしての特徴
- ユーザは、クライアントから、サーバにアクセスして、ゲームサービスを得る
- Client/Server型ゲーム
- 例えば「ダンジョンを選んで(通信!)、戦って(通信!)、報酬を得る(通信!)」という一連の流れで、ゲームサービスになる
- C/S型ゲームでは、スマブラは作れない
- 通信頻度をどんなに減らしても光の速さには抗えないので、反射神経を求めるようなゲームはは作れない
ゲームとしての特徴
- けれど、どうにかしたい
- 「考える時間」 (例)パズル、クイズなど
- 「表現」
- 「通信の仕方」
WEBサービスとしての特徴
- ユーザは、行動に応じた対価を得るサービス
- 対価を作るために UPDATEが多い
- 対価は主にユーザ自身が得るので、更新対象はユーザ自身が中心
- [参考] 松信先生のありがたいスライド http://www.slideshare.net/matsunobu/ss-6584540
代表的なスキーマ
- ユーザ
- カード
- 称号
ユーザ
昔
- 主キーは、採番
- 属性カラムが多い
CREATE TABLE user (
id int(10) unsigned NOT NULL AUTO_INCREMENT,
os_id varchar(50) NOT NULL,
level int(10) unsigned NOT NULL,
exp int(10) unsigned NOT NULL,
money int(10) unsigned NOT NULL,
rare_medal int(10) unsigned NOT NULL,
guild_stone int(10) unsigned NOT NULL,
mission_point int(10) unsigned NOT NULL COMMENT 'ミッション用行動ポイント(現在値)',
max_mission_point int(10) unsigned NOT NULL COMMENT 'ミッション用行動ポイント(最大値)',
colosseum_point int(10) unsigned NOT NULL COMMENT '闘技場用行動ポイント(現在値)',
max_colosseum_point int(10) unsigned NOT NULL COMMENT '闘技場用行動ポイント(最大値)',
unit_max_count int(10) unsigned NOT NULL COMMENT 'unitの最大所持数',
unit_use_point int(10) unsigned NOT NULL COMMENT 'unitを使うためのポイント / unit.use_pointと対応',
leader_user_unit_id int(10) unsigned NOT NULL COMMENT 'リーダーカードのID',
equipment_max_count int(10) unsigned NOT NULL COMMENT '武具の最大所持数',
gacha_point int(10) unsigned NOT NULL COMMENT 'ガチャを回すためのポイント(エールP) greetなどで溜まる',
last_unconnect_at datetime NOT NULL COMMENT '最後にいずれかの仲間を解除した時間',
mission_point_recover_seconds int(10) unsigned NOT NULL COMMENT '行動力回復に必要な時間 / 回復量は設定に',
mission_point_last_recovered_at datetime NOT NULL COMMENT '最後に行動力を回復した時間',
colosseum_point_recover_seconds int(10) unsigned NOT NULL COMMENT '闘志回復に必要な時間 / 回復量は設定に',
colosseum_point_last_recovered_at datetime NOT NULL COMMENT '最後に闘志を回復した時間',
guild_battle_point_recover_seconds int(10) unsigned NOT NULL COMMENT '士気回復に必要な時間 / 回復量は設定に',
guild_battle_point_last_recovered_at datetime NOT NULL COMMENT '最後に士気を回復した時間',
partner_id int(10) unsigned COMMENT '選択中のパートナーのID',
last_present_at datetime NOT NULL COMMENT '最後にプレゼントした時間',
present_count int(10) unsigned COMMENT 'プレゼントした回数',
text_api_id varchar(50) COMMENT '自己紹介用TextDataAPIのID',
text_api_id_last_updated_at datetime COMMENT '最後に自己紹介を更新した時間',
tutorial_progress int(10) unsigned COMMENT 'チュートリアルをどこまで読んだか。チュートリアルの画面ID',
tutorial_end_fg tinyint NOT NULL DEFAULT '0' COMMENT 'チュートリアル終了フラグ',
tutorial_end_at datetime NOT NULL COMMENT 'チュートリアル終了時刻',
colosseum_tutorial_end_fg tinyint NOT NULL DEFAULT '0' COMMENT '闘技場チュートリアル終了フラグ',
invite_count int(10) unsigned NOT NULL DEFAULT 0 COMMENT '友だちを招待した数',
invite_user_id int(10) unsigned NULL COMMENT '招待したユーザ',
max_friend_count int(10) unsigned NOT NULL COMMENT '最大仲間人数',
created_at datetime NOT NULL,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY `os_id` (`os_id`),
KEY `level` (`level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='ユーザー';
最近
- 更新頻度の高いポイント類を別スキーマに分けた
- アプリケーションを作る為に、
- スキーマの責任範囲が狭くなって、考えることが少なくなった
- ポイントの種類が増えれば、またデータカラム増えるだけで済む(多少まし程度の話)
- [try]ポイントの種類に応じてテーブル分けるのもあり。
- パフォーマンス的に
- キャッシュの生存時間のばす
カード
昔
- user_unit->unit->unit_base
- 所有者 -> 育てたカード -> カードの元データ
昔
所有者
CREATE TABLE user_unit (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INTEGER UNSIGNED NOT NULL,
unit_id INTEGER UNSIGNED NOT NULL,
unit_base_id INTEGER UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY user_id (user_id, unit_id),
KEY unit_id (unit_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='カードの所有者情報';
育てたカード
CREATE TABLE unit (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
unit_base_id INTEGER UNSIGNED NOT NULL,
attack_point INTEGER UNSIGNED NOT NULL COMMENT '攻撃力',
guard_point INTEGER UNSIGNED NOT NULL COMMENT '防御力',
stamina_point INTEGER UNSIGNED NOT NULL COMMENT '耐久力',
use_point INTEGER UNSIGNED NOT NULL COMMENT 'コスト',
level INTEGER UNSIGNED NOT NULL COMMENT 'レベル',
growth INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT '成長',
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='カード個別データ';
昔
元データ
CREATE TABLE unit_base (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(32) CHARSET ascii COLLATE ascii_bin NOT NULL,
name_ja VARCHAR(32) NOT NULL,
description TEXT NULL,
element ENUM('god', 'devil', 'human') NOT NULL COMMENT '属性',
rarity ENUM('N', 'NP', 'R', 'RP', 'HR', 'HRP', 'SR', 'SRP', 'UR', 'URP', 'LG', 'LGP') NOT NULL COMMENT 'レア度',
attack_point INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT '攻撃力',
guard_point INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT '防御力',
use_point INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT 'コスト',
money INTEGER UNSIGNED NOT NULL COMMENT '売却価格',
max_level INTEGER UNSIGNED NOT NULL COMMENT '最大レベル',
attack_point_when_rising_levelup INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT 'レベルアップ時の攻撃力の上がり幅',
guard_point_when_rising_levelup INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT 'レベルアップ時の防御の上がり幅',
ability_id INTEGER UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='ユニット基盤データ';
昔
- 「育てたカード」と「カードを所持している人」とを別枠で保持
- 育てたカードの所有者をすげかえて、トレードになる
今
- user_unit->unit
- 所有者 & 育てたカード -> カードの元
今
CREATE TABLE user_unit (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INTEGER UNSIGNED NOT NULL,
unit_id INTEGER UNSIGNED NOT NULL,
exp INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT '経験値',
unit_acquired_count INTEGER UNSIGNED NOT NULL DEFAULT 1 COMMENT 'ユニット獲得回数',
additional_level_limit INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT '追加レベル上限',
use_count INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT 'ユニット使用回数(バトル/ストーリー)',
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY user_unit_uniq (user_id, unit_id),
INDEX unit_id_idx (unit_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='所有ユニット';
今
CREATE TABLE unit (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name_ja VARCHAR(32) NOT NULL,
default_level_limit INTEGER UNSIGNED NOT NULL COMMENT '初期値(レベル上限)',
default_physical_energy INTEGER UNSIGNED NOT NULL COMMENT '初期値(体力)',
... 中略 m(_ _)m ...
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='ユニット';
- マスターテーブルは変わらず1枚で、ユーザ情報が1枚減った
- 攻撃力、防御力などのパラメタの保持してない(計算で算出)
昔と今の変化理由
- トレードのありなし
- ゲームレベルデザインの調整のしやすさ
- 元栓一個
- 主に運用前のメリット
- 万が一のゲームバグにも対応しやすさが、若干ある。
- 履歴の保持をあんまり意識しないで済むので。
- 例えば「無限増殖」「100円で買えるアイテムを、150円で売れる」みたいな話
称号
例)「ユーザがレベル10になったら、なにがしかの"称号"が与えられる」
昔
CREATE TABLE achievement (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
group_name ENUM(
'camel'
'onion'
'pumpking'
'etc'
) NOT NULL DEFAULT 'etc',
score INT UNSIGNED NOT NULL,
cond_data TEXT NOT NULL, -- XXX なんぞこれwww
description TEXT NOT NULL,
value INT UNSIGNED DEFAULT NULL COMMENT '称号付与の閾値となる値',
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
今
CREATE TABLE emblem_tmpl (
id INTEGER UNSIGNED NOT NULL PRIMARY KEY, -- 種別ごとにidがまとまっている方が管理しやい
emblem_group_id INTEGER UNSIGNED NOT NULL,
threshold INTEGER UNSIGNED NOT NULL COMMENT '貰えるまでの閾値',
name_ja VARCHAR(255) NOT NULL COMMENT '称号名のテンプレ',
description TEXT NOT NULL COMMENT '称号説明のテンプレ',
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX emblem_group_threshold_idx (emblem_group_id, threshold)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='称号テンプレ';
今
CREATE TABLE emblem (
id INTEGER UNSIGNED NOT NULL PRIMARY KEY, -- 種別ごとにidがまとまっている方が管理しやすい
emblem_group_id INTEGER UNSIGNED NOT NULL,
emblem_tmpl_id INTEGER UNSIGNED NOT NULL,
target_uniqkey VARCHAR(64) CHARSET ascii COLLATE ascii_bin NULL COMMENT '称号の対象条件のuniqkey',
name_ja VARCHAR(255) NOT NULL COMMENT '称号名', -- emblem_tmplから生成
description TEXT NOT NULL COMMENT '称号説明', -- emblem_tmplから生成
threshold INTEGER UNSIGNED NOT NULL COMMENT '貰えるまでの閾値', -- emblem_tmplから生成
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY emblem_group_emblem_tmpl_target_uniqkey_uniq (emblem_group_id, emblem_tmpl_id, target_uniqkey), -- ある称号種別の閾値100の称<
INDEX emblem_group_target_uniqkey_idx (emblem_group_id, target_uniqkey) -- ある称号種別のアッシュの称号一覧を見たい
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='称号';
変遷の理由
- マスターデータの元に出るテンプレデータもスキーマに落とし込む
- 個別に設定したいなら、子供の方を修正してやる
- ゲームデータを作りやすく
蛇足
時間の関係で割愛...
- idを無意識につけるのはやめよう
- @fujiwara先生 「サロゲートkeyが常に必要なわけではないし、そういう場合は適切な主keyのみでcovering indexになることもある」
- [参考] covering indexについては、kazeburo先生の資料 http://www.slideshare.net/kazeburo/isucon-summerclass2014action2final
- Partition
- データを計画的に削除
応用例
- バトル
- トーナメントバトル
バトル
「skillを持ったFighterが、隊をなして、相手か自分が殲滅するまで、HPステータスの削り合いをする」
昔
- 素朴なゲームループ
- 終了条件まで、ループしてあげる
- ゲームを始める為に必要なデータを、必要なテーブルから自分で全部取ってあげないといけない
- 何が必要か考えるの面倒
package MyGame::Model::Battle;
my $fighters = model('Fighter')->fetch_party(+{ user_id => $user->id });
my $opposite_fighters = model('Fighter')->fetch_party(+{ user_id => $opposite_user->id });
my $turn = 0;
while (my $turn <= MAX_TURN ) {
$fighter->attack($opposite_fighters, $fighters);
$turn++;
}
今
- Battle Managerの用意
- ロック順の調整
- 処理の見通し
- view側でのニーズ
- あれもこれもstash祭りとかあほらしい
my $battle_manager = model('Battle')->manager(user => $user, opposite_user => $opposite_user);
my $fighters = $battle_manager->fighters;
my $opposite_fighters = $battle_manager->opposite_fighters;
.. 略 ..
余談
- バトルを組み上げるの地味にロジックが多いので、こんな感じでやっているよという例
2つの役割を用意
- Model: ゲームロジックの責任
- Model has many tables
- ゲームロジック(ビジネスロジック)に関心
- DB: データの操作の責任
- DB has a table
- 技術レイヤーに関心
処理の流れ
:) -> Controller -> Model -> DB -> mysql, redis, memcached etc
Modelの例
- ビジネスロジックを大きく3つに分類
- rwなaccecorがあるobj (例) Fighter
- rwなaccecorがないobj (例) Skill
- それらが協調しあうpackage (例) Battle [参考] DDD / Entity, Value Object, Service
DBの例
- observeパターン使いやすいように、Exception!Exception!
- handle_errorのoverrideとか
- [参考] songumu先生のブログ! http://www.songmu.jp/riji/entry/2013-08-05-exception.html
トーナメントバトル
- 日曜日の0:00からバトル開始!
昔
- トーナメントの全組み合わせが終わるまで、txnをかける
今
- トーナメントの組み合わせ毎に、txnをかける
- 全対戦が終了したか監視する
対戦の組み合わせ毎のスキーマを用意
CREATE TABLE arena_tournament_group_match (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
arena_tournament_group_id INTEGER UNSIGNED NOT NULL,
battle_count INTEGER UNSIGNED NOT NULL COMMENT '何回戦か', -- 1回戦から
match_num INTEGER UNSIGNED NOT NULL COMMENT '対戦番号', -- 1番から
user_id INTEGER UNSIGNED NULL,
status ENUM(
'before_battle', -- 戦闘開始前
'end' -- 戦闘終了( is_odd(MN) ? MN + 1 に勝利/敗北 : MN - 1 に勝利/敗北 )
) NOT NULL DEFAULT 'before_battle' COMMENT '状態',
opposite_user_id INTEGER UNSIGNED NULL COMMENT '対戦相手のuserのid',
npc_fg BOOL NULL DEFAULT 0,
won_fg BOOL NULL,
user_bout_log_id INTEGER UNSIGNED NULL COMMENT '戦闘ログid',
created_at DATETIME NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY arena_tournament_group_round_match_num_uniq (arena_tournament_group_id, battle_count, match_num),
INDEX arena_tournament_battle_user_idx (arena_tournament_group_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin COMMENT='アリーナトーナメント対戦表';
工夫したこと(仕様面)
- ユーザ操作がなくてもゲーム性を維持する為の工夫
- ランダム性を敢えてなくした、戦略バトルにふる -> 企画側
- 技術的には、疑似乱数のseedを共有でも対応できる
工夫したこと(処理面)1
- 小さなtxnにする
- 巻き戻す時に、undo logあふれるとかやだ。
- 対戦の状況を監視してやる
- 1位が決まるまで見張ってやる
- txnを分割
工夫したこと(処理面)2
- 行ロックを取り合わないようにする
- 監視の優先順位で調整
- managerの場合はロック順を整備をした。
- 一方、トーナメントは、そもそもお互いが関わり合わないようにする
- どこで問題が起きたかを特定しやすくする
- どういう状態の対戦だった時、こけたのか保持
- 想定時間内にトーナメントが終わらなければ、アラートを飛ばす
まとめ
- スキーマは、DBにどうデータが入るかの定義
- 誰が使うのか、何をしたいかで変化
- 主キーに注目
- ソーシャルゲームは、C/S型ゲーム
- 更新多め、更新対象は自分 etc
- ゲームの調整のしやすさも用意
- 実装は、複数tableが多く絡んだ処理を紹介
- Managerの用意
- 小さなtxnを監視
ご清聴ありがとうございました
株式会社モバイルファクトリー は、
開発好きなエンジニアを求めています。
social game schema
By kfly8
social game schema
talk at YAPC::Asia 2014. http://yapcasia.org/2014/talk/show/ce6e777e-fb91-11e3-b7e8-e4a96aeab6a4
- 984