ほんとにあったスキーマ

「ソーシャルゲーム」

自己紹介

大事な連絡

ブログを書くまでが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='称号';

変遷の理由

  • マスターデータの元に出るテンプレデータもスキーマに落とし込む
    • 個別に設定したいなら、子供の方を修正してやる
  • ゲームデータを作りやすく

蛇足

時間の関係で割愛...

応用例

  • バトル
  • トーナメントバトル

バトル

「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の例

トーナメントバトル

トーナメント表

  • 日曜日の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