iT邦幫忙

2024 iThome 鐵人賽

DAY 12
0

Current Sprint: 2. 實作遊戲開始
repo: https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan
swagger docs: https://side-project-at-spt.github.io/ithome-ironman-2024-san-juan/

前情提要

  • 完成洗勻價格卡

TODO

  1. 產生並儲存 random seed
  2. 洗勻價格卡
  3. 設置牌庫:抽出玩家人數的染坊卡片,洗勻剩餘卡牌成為牌庫(抽牌堆)
  4. 發給每位玩家一張染坊卡片,作為起始建築
  5. 玩家依序從牌庫抽取四張卡片,作為手牌
  6. 決定起始玩家

設置牌庫

image

ref: #183 聖胡安 SAN JUAN 桌遊教學影片|勃根地桌遊

參照 卡表 可以知道

基本版共有 110 張卡片,其中

工廠建築: 5 種 共 42 張
城市建築:24 種 共 68 張

先實作「染坊」卡片,並填上「空白牌」組成牌庫

  1. 卡片

卡片的屬性有:

  • 名稱
  • 價格
  • 類型:工廠 | 城市
  • 分數
  • 張數
  • id (產生卡片物件時用)
  1. 染坊
{
  "id": "01",
  "name": "Indigo Plant",
  "price": 1,
  "type": "Production",
  "score": 1,
  "amount": 10
}
# app/models/cards/indigo_plant.rb

class Cards::IndigoPlant < Cards::BaseCard
  class << self
    def id
      "01"
    end
  end

  def name
    "Indigo Plant"
  end

  def price
    1
  end

  def type
    "Production"
  end

  def score
    1
  end

  def amount
    10
  end
end
  1. 填充用的空白卡片
# app/models/cards/blank_card.rb

class Cards::BlankCard < Cards::BaseCard
  class << self
    def id
      "00"
    end
  end

  def name
    "Blank Card"
  end

  def price
    1
  end

  def type
    "Production"
  end

  def score
    1
  end

  def amount
    -1
  end
end
  1. 繼承的卡片原型
# app/models/cards/base_card.rb

class Cards::BaseCard
  # declare DECK_SIZE to fill the deck with blank cards
  DECK_SIZE = 110.freeze

  @@card_class = {}

  class << self
    def card_class
      @@card_class
    end

    def <<(klass)
      @@card_class[klass.id] = klass
    end

    def id
      raise NotImplementedError
    end
  end

  def name
    raise NotImplementedError
  end

  def price
    raise NotImplementedError
  end

  def type
    raise NotImplementedError
  end

  def score
    raise NotImplementedError
  end

  def amount
    raise NotImplementedError
  end

  def id
    self.class.id
  end

  def to_h
    {
      id: id,
      name: name,
      price: price,
      type: type,
      score: score,
      amount: amount
    }
  end
end

class String
  def to_card
    if self.in? Cards::BaseCard.card_class.keys
      Cards::BaseCard.card_class[self].new
    else
      raise "#{self} is not a valid card id"
    end
  end
end

Cards::BaseCard << Cards::BlankCard
Cards::BaseCard << Cards::IndigoPlant
# Cards::BaseCard.card_class["02"] = Cards::SugarMill
# Cards::BaseCard.card_class["03"] = Cards::TobaccoStorage
# Cards::BaseCard.card_class["04"] = Cards::CoffeeRoaster
# Cards::BaseCard.card_class["05"] = Cards::SilverSmelter
# Cards::BaseCard.card_class["06"] = Cards::Smithy
# Cards::BaseCard.card_class["07"] = Cards::GoldMine
# Cards::BaseCard.card_class["08"] = Cards::Archive
# Cards::BaseCard.card_class["09"] = Cards::PoorHouse
# Cards::BaseCard.card_class["10"] = Cards::BlackMarket
# Cards::BaseCard.card_class["11"] = Cards::TradingPost
# Cards::BaseCard.card_class["12"] = Cards::Well
# Cards::BaseCard.card_class["13"] = Cards::MarketStand
# Cards::BaseCard.card_class["14"] = Cards::Crane
# Cards::BaseCard.card_class["15"] = Cards::Chapel
# Cards::BaseCard.card_class["16"] = Cards::Tower
# Cards::BaseCard.card_class["17"] = Cards::Aqueduct
# Cards::BaseCard.card_class["18"] = Cards::Carpenter
# Cards::BaseCard.card_class["19"] = Cards::Prefecture
# Cards::BaseCard.card_class["20"] = Cards::MarketHall
# Cards::BaseCard.card_class["21"] = Cards::Quarry
# Cards::BaseCard.card_class["22"] = Cards::Library
# Cards::BaseCard.card_class["23"] = Cards::Statue
# Cards::BaseCard.card_class["24"] = Cards::VictoryColumn
# Cards::BaseCard.card_class["25"] = Cards::Hero
# Cards::BaseCard.card_class["26"] = Cards::GuildHall
# Cards::BaseCard.card_class["27"] = Cards::CityHall
# Cards::BaseCard.card_class["28"] = Cards::TriumphalArch
# Cards::BaseCard.card_class["29"] = Cards::Palace
# # Expansion: "The New Buildings"
# Cards::BaseCard.card_class["30"] = Cards::GuardRoom
# Cards::BaseCard.card_class["31"] = Cards::OfficeBuilding
# Cards::BaseCard.card_class["32"] = Cards::Hut
# Cards::BaseCard.card_class["33"] = Cards::Tavern
# Cards::BaseCard.card_class["34"] = Cards::Park
# Cards::BaseCard.card_class["35"] = Cards::CustomsOffice
# Cards::BaseCard.card_class["36"] = Cards::Bank
# Cards::BaseCard.card_class["37"] = Cards::Harbor
# Cards::BaseCard.card_class["38"] = Cards::Goldsmith
# Cards::BaseCard.card_class["39"] = Cards::Residence
# Cards::BaseCard.card_class["40"] = Cards::Cathedral

這個 class 負責了:

  1. 定義卡片的共同介面
  2. 宣告共用的常數
  3. 收集了所有卡片的 class,方便由 BaseCard 去產生各種卡片物件

並擴充了 String class,可以透過 to_card 方法產生物件

rails run 'puts "01".to_card.to_h.to_json' | jq
{
  "id": "01",
  "name": "Indigo Plant",
  "price": 1,
  "type": "Production",
  "score": 1,
  "amount": 10
}
  1. 修改 game_spec.rb
# spec/requests/api/v1/game_spec.rb

RSpec.describe "Api::V1::Games", type: :request do

# ...
  
    post 'Create a game' do
      tags 'Games'
      consumes 'application/json'
      produces 'application/json'

      parameter name: :payload, in: :body, schema: {
        type: :object,
        properties: {
          seed: { type: :string, example: '1234567890abcdef', description: 'Optional' }
        }
      }

      let(:payload) { { seed: '1234567890abcdef' } }

      response '200', 'Game created' do
        schema type: :object,
          properties: {
            id: { type: :integer },
            status: { type: :string }
          },
          required: [ 'id', 'status' ]

        run_test! do
          json = JSON.parse(response.body)
          expect(json['status']).to eq('playing')
          expect(json['game_config']['seed']).to eq('1234567890abcdef')
          expect(json['game_data']['current_price']).to match_array([ 1, 2, 2, 2, 3 ])
          expect(json['game_data']['supply_pile'].size).to eq(110 - 4)
          expect(json['game_data']['supply_pile'][8]).to eq("01")
        end
      end
    end
  end

# ...

end
  1. 更新 GamesController,讓 create 可以接受傳入 seed
# app/controllers/api/v1/games_controller.rb

class Api::V1::GamesController < ApplicationController
  
# ...

  def create
    @game = Game.start_new_game(seed: params[:seed])

    render json: { error: @game.errors.full_messages } unless @game.present?
  end

# ...
  
end
  1. 修改 views
# app/views/api/v1/games/_game.json.jbuilder

# ...

  json.game_data do
    json.current_price game.current_price
    json.supply_pile game.game_data["supply_pile"]
  end

# ...

  1. 修改 Game model,讓 start_new_game 可以接受 seed 參數,並新增 generate_deck 產生 Array<id: string>

在遊戲中只要有 card id,就能產生卡片物件並進行互動

# app/models/game.rb

class Game < ApplicationRecord
  enum :status, {
    unknown: 0,
    playing: 1,
    finished: 2
  }, prefix: true

  def play
    return errors.add(:status, "can't be blank") unless status_playing?

    self.status_finished!
  end

  class << self
    def generate_seed = SecureRandom.hex(16)

    def start_new_game(seed: nil)
      game = new(status: :playing)
      # 1. generate a random seed
      game.seed = seed || SecureRandom.hex(16)

      # 2. shuffle the 5 trading house tiles
      game.game_data[:trading_house_order] = TradingHouse.new(seed: game.seed).order

      # 3. remove 1 indigo plant from the deck for each player
      #    shuffle the remaining cards, forming a supply pile
      # 3.1. generate the deck
      deck = generate_deck

      # 3.2. remove 1 indigo plant from the deck for each player
      deck.shift(game.players.size)

      # 3.3. shuffle the remaining cards to form a supply pile
      deck.shuffle!
      game.game_data[:supply_pile] = deck

      # 4. deal 4 cards to each player as their initial hand, hidden from other players
      # 5. choose first player
      game.save

      game
    end

    private

    def generate_deck
      # 3.1. initialize the deck
      # 3.1.1. determine how many cards in the deck
      deck_size = Cards::BaseCard::DECK_SIZE

      # 3.1.2. generate a deck in order
      deck = Cards::BaseCard.card_class.keys.map do |id|
        next if id == Cards::BlankCard.id

        Cards::BaseCard.card_class[id].new.amount.times.map { id }
      end
      deck = deck.flatten.compact

      # 3.1.3. fill the deck with blank cards if deck size is not enough
      blank_card_id = Cards::BlankCard.id
      deck += (deck_size - deck.size).times.map { blank_card_id }
    end
  end

  def current_price
    TradingHouse.new(game_data["trading_house_order"]).current_price
  end

  # FIXME: hardcode 4 players for now
  def players
    Struct.new(:size).new(
      size: 4
    )
  end
end
  1. initializers preload cards/ class,避免找不到定義
# config/initializers/preload_game_model.rb

# https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#option-3-preload-a-regular-directory

unless Rails.application.config.eager_load
  Rails.application.config.to_prepare do
    Rails.autoloaders.main.eager_load_dir(Rails.root.join("app/models/cards"))
  end
end

收工.

  1. 產生並儲存 random seed
  2. 洗勻價格卡
  3. 抽出玩家人數的染坊卡片,洗勻剩餘卡牌成為牌庫(抽牌堆)
  4. 發給每位玩家一張染坊卡片,作為起始建築
  5. 玩家依序從牌庫抽取四張卡片,作為手牌
  6. 決定起始玩家

小結

也可以直接看 diff b794589 /images/emoticon/emoticon37.gif

明天要做什麼

Sprint 2: 實作遊戲開始

以上不代表明天會做,如有雷同純屬巧合


工商服務

SPT (Side Project Taiwan) 的宗旨是藉由Side Project開發來成就自我,透過持續學習和合作,共同推動技術和專業的發展。我們相信每一個參與者,無論是什麼專業,都能在這個社群中找到屬於自己的成長空間。

歡迎所有對Side Project開發有興趣的人加入我們,可以是有點子來找夥伴,也可以是來尋找有興趣的Side Project加入,邀請大家一同打造一個充滿活力且有意義的技術社群!

Discord頻道連結:https://sideproj.tw/dc


上一篇
Day 11 - S2: 洗勻價格卡
下一篇
Day 13 - S2: 發給玩家染坊卡片作為起始建築
系列文
透過實作網頁遊戲練習網站工程師的基本素養,以 San Juan(聖胡安) 為例。30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言