JavaScriptでシェフィを実装する(UI本格化編/カードスタック可視化の巻)


2014年 09月 17日

前回までのあらすじ

シェフィの実装を始めた俺達はようやく仮UIをまともにしようと活動を始めたばかり。何となくそれっぽい感じにカードを表示してみたものの、まだまだやることは沢山残っている。

よりビジュアル系になった図

果たしてよりカッコイイ見た目にできるのであろうか……?

(※ソースコードはGitHubで公開されておりすぐに遊ぶこともできます)

今回のあらすじ

前回、個々のカードをそれっぽく表示するところまでは行いましたが、いくつか改善すべき点が残っていました:

  • 捨て山や追放領域はそれっぽいカードが並ぶ形になったものの、いささかスペースを取り過ぎです。実物で遊ぶ時はカードを重ねて置くことの方が多いので、それに倣った表示にしたいものです。
  • 山札やひつじ山の表示は無機質な数字のままでした。これは暖かみのあるカードっぽい何かの束で表示したいものです。

と言う訳で順番に片付けていきましょう。

事前準備 – 個別のカードの表現方法の調整

本題に入る前にちょっと整理をしておきます。
個々のカードを表すDOMツリーを返す visualizeCard ですが、
前回では以下のような実装にしていました:

function visualizeCard(card) {
  var $body = $('<span>');
  $body.addClass('body');
  $body.text(card.name);

  var $card = $('<span>');
  $card.addClass('card');
  $card.addClass(cardType(card));
  $card.addClass('rank' + card.rank);
  $card.append($body);
  return $card;
}

このままだとカードの位置を自由自在に調整しようとした時に面倒なので、
カードの外枠用にもう一段階レイヤー(.border)を追加することにします:

function visualizeCard(card) {
  var $body = $('<span>');
  $body.addClass('body');
  $body.text(card.name);

  var $border = $('<span>');
  $border.addClass('border');
  $border.append($body);

  var $card = $('<span>');
  $card.addClass('card');
  $card.addClass(cardType(card));
  $card.addClass('rank' + card.rank);
  $card.append($border);
  return $card;
}

これに合わせてCSSも微調整しておきます。

.card {
  display: inline-block;
  margin: 0.5ex;
}

.card > .border {
  display: inline-block;
  border: thin solid #000;
}

.card > .border > .body {
  overflow: hidden;
  white-space: nowrap;
}

/* ... 以下省略 ... */ 

以上の変更で見た目は変わらないものの、
以降の表示の調整がやり易くなります。

捨て山や追放領域のカード達を並べて表示する

追放領域はさておいて、捨て山は割と大量のカードが並びます。
現状の表示はカードとカードの間にある程度マージンを入れているのですが、
このマージンは手札の表示には良いとしても、
捨て山に対してはいささか横幅を取り過ぎています。

横に間延びした捨て山の表示

と言う訳でカードを重ね気味に並べて表示してコンパクトな見た目にしましょう。

HTML側では捨て山と追放領域に該当する部分を以下の形で記述していましたが:

<div id="discardPile">Discard pile: <span class="cards"></span></div>
<div id="exile">Exile: <span class="cards"></span></div>

表示形態を区別する為に class を追記しましょう:

<div id="discardPile">Discard pile: <span class="lined cards"></span></div>
<div id="exile">Exile: <span class="lined cards"></span></div>

後はCSSをちょいと調整すれば完成です:

.lined.cards > .card {
  margin: 0;
  width: 0;
  overflow: visible;
}

.lined.cards > .card {margin-left: 1.5em;}
.lined.cards > .card:first-child {margin-left: 0;}

こうすると以下のような表示になります:

やや横にシェイプアップされた捨て山の表示

うーん。間延びしていた頃よりは良いと思いますが、何だか綺麗に並び過ぎてて暖かみに欠けますね。
実物のカードをここまでキッチリ並べることは不可能なので、もう少し遊びを入れてみることにしましょう:

.lined.cards > .card:nth-child( 1) {transform: rotate(-4deg);}
.lined.cards > .card:nth-child( 2) {transform: rotate( 0deg);}
.lined.cards > .card:nth-child( 3) {transform: rotate( 2deg);}
.lined.cards > .card:nth-child( 4) {transform: rotate(-3deg);}
.lined.cards > .card:nth-child( 5) {transform: rotate( 1deg);}
.lined.cards > .card:nth-child( 6) {transform: rotate( 4deg);}
.lined.cards > .card:nth-child( 7) {transform: rotate( 2deg);}
.lined.cards > .card:nth-child( 8) {transform: rotate( 1deg);}
.lined.cards > .card:nth-child( 9) {transform: rotate( 4deg);}
.lined.cards > .card:nth-child(10) {transform: rotate(-3deg);}
.lined.cards > .card:nth-child(11) {transform: rotate(-4deg);}
.lined.cards > .card:nth-child(12) {transform: rotate( 0deg);}
.lined.cards > .card:nth-child(13) {transform: rotate( 4deg);}
.lined.cards > .card:nth-child(14) {transform: rotate(-1deg);}
.lined.cards > .card:nth-child(15) {transform: rotate(-3deg);}
.lined.cards > .card:nth-child(16) {transform: rotate( 1deg);}
.lined.cards > .card:nth-child(17) {transform: rotate( 2deg);}
.lined.cards > .card:nth-child(18) {transform: rotate(-1deg);}
.lined.cards > .card:nth-child(19) {transform: rotate( 1deg);}
.lined.cards > .card:nth-child(20) {transform: rotate(-3deg);}
.lined.cards > .card:nth-child(21) {transform: rotate(-4deg);}
.lined.cards > .card:nth-child(22) {transform: rotate( 0deg);}

結果はこうなります:

暖かみのある捨て山の表示

まあこんなところではないでしょうか。

ひつじ山をカードの束として表示する

さて、次はひつじ山の見た目を整えましょう。
まずはHTML側を調整します:

<div id="sheepStock">
  Sheep stocks:
  <div id="sheepStock1" class="stacked cards"></div>
  <div id="sheepStock3" class="stacked cards"></div>
  <div id="sheepStock10" class="stacked cards"></div>
  <div id="sheepStock30" class="stacked cards"></div>
  <div id="sheepStock100" class="stacked cards"></div>
  <div id="sheepStock300" class="stacked cards"></div>
  <div id="sheepStock1000" class="stacked cards"></div>
</div>

次にCSSをちょろっと書き足して:

.stacked.cards > .card,
.lined.cards > .card {
  margin: 0;
  width: 0;
  overflow: visible;
}

.stacked.cards > .card {position: relative;}
.stacked.cards > .card:nth-child( 1) {top: 0.00ex; left: 0.00ex;}
.stacked.cards > .card:nth-child( 2) {top: 0.30ex; left: 0.30ex;}
.stacked.cards > .card:nth-child( 3) {top: 0.60ex; left: 0.60ex;}
.stacked.cards > .card:nth-child( 4) {top: 0.90ex; left: 0.90ex;}
.stacked.cards > .card:nth-child( 5) {top: 1.20ex; left: 1.20ex;}
.stacked.cards > .card:nth-child( 6) {top: 1.50ex; left: 1.50ex;}
.stacked.cards > .card:nth-child( 7) {top: 1.80ex; left: 1.80ex;}
.stacked.cards > .card:nth-child( 8) {top: 2.10ex; left: 2.10ex;}
.stacked.cards > .card:nth-child( 9) {top: 2.40ex; left: 2.40ex;}
.stacked.cards > .card:nth-child(10) {top: 2.70ex; left: 2.70ex;}
.stacked.cards > .card:nth-child(11) {top: 3.00ex; left: 3.00ex;}
.stacked.cards > .card:nth-child(12) {top: 3.30ex; left: 3.30ex;}
.stacked.cards > .card:nth-child(13) {top: 3.60ex; left: 3.60ex;}
.stacked.cards > .card:nth-child(14) {top: 3.90ex; left: 3.90ex;}
.stacked.cards > .card:nth-child(15) {top: 4.20ex; left: 4.20ex;}
.stacked.cards > .card:nth-child(16) {top: 4.50ex; left: 4.50ex;}
.stacked.cards > .card:nth-child(17) {top: 4.80ex; left: 4.80ex;}
.stacked.cards > .card:nth-child(18) {top: 5.10ex; left: 5.10ex;}
.stacked.cards > .card:nth-child(19) {top: 5.40ex; left: 5.40ex;}
.stacked.cards > .card:nth-child(20) {top: 5.70ex; left: 5.70ex;}
.stacked.cards > .card:nth-child(21) {top: 6.00ex; left: 6.00ex;}
.stacked.cards > .card:nth-child(22) {top: 6.30ex; left: 6.30ex;}

#sheepStock > .stacked.cards {
  display: inline-block;
  width: 10ex;
}

後は drawGameTree におけるひつじ山のレンダリング方法をテキストからカードに差し替えます:

S.RANKS.forEach(function (rank) {
  $('#sheepStock' + rank).html(visualizeCards(w.sheepStock[rank]));
});

結果はこの通り:

非常に直感的なひつじ山の表示

おお……大分それっぽい。やる気が湧いてきますね。

山札をカードの束として表示する

最後に、山札の表示を改善しましょう。
実物で遊ぶ時はカードの山を作るものですが、
この実装で山として表示すると物凄く右下に伸びちゃいますし、
かといってカード間の隙間を減らすと残り枚数が視覚的に認識し辛くなります。
と言う訳で山札は捨て山のようにカードを並べて表示することにしましょう。
ただし、捨て山のようにカード名を見せる必要は無いので、
捨て山よりもカード間の隙間は狭めにしましょう。

隙間の度合いの調整も含めて、HTMLの調整は以下のようにしましょう:

<div id="deck">Deck: <span class="tightly lined cards"></span></div>
<div id="discardPile">Discard pile: <span class="loosely lined cards"></span></div>
<div id="exile">Exile: <span class="loosely lined cards"></span></div>

CSSの調整は一瞬で済みますね:

.tightly.lined.cards > .card {margin-left: 0.25em;}
.loosely.lined.cards > .card {margin-left: 1.5em;}
.lined.cards > .card:first-child {margin-left: 0;}

ただし問題は drawGameTree です。
他の領域と異なり、山札のカードは裏向きになっています。
一先ず裏向きのカードをN個作る makeFaceDownCards があるものと仮定しましょう:

$('#deck > .cards').html(visualizeCards(makeFaceDownCards(w.deck.length)));

makeFaceDownCards はざっくり以下のように実装しましょう:

function makeFaceDownCards(n) {
  var cards = [];
  for (var i = 0; i < n; i++)
    cards.push({name: '', type: 'face-down'});
  return cards;
}

それと、カード種別を判断する cardType もこれに合わせて変更しておきましょう:

function cardType(card) {
  return card.type || (card.rank === undefined ? 'event' : 'sheep');
}

最後に裏向きのカード用のCSSを書き足せば完成です:

.card.face-down {
  line-height: 0;
}
.card.face-down > .border {
  background: #393;
}
.card.face-down > .border > .body {
  display: inline-block;
  width: 4.6ex;
  height: 6.8ex;
  margin: 0.5ex;
  padding: 0.5ex;
  border-radius: 1ex;
  background: #9d9;
}

結果はこうなります:

暖かみのある山札の表示例

次回予告

うーむ。なかなか良い感じの盤面の表示になってきました。

頑張ってビジュアル系にした盤面

最初の仮UIに比べればずいぶんやる気が出てきそうな気がします。
見た目に凝り始めるとキリがないので、
そろそろ利便性を考慮した改善を行いたいものです。

という訳で次回はUI本格化編/オンラインヘルプの巻です。