10

สร้าง Rock-Paper-Scissors แบบค่อย ๆ ต่อทีละช่วง

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

th/topic_10_rock_paper_scissors

ภาพรวม

ทำไมหัวข้อนี้จึงสำคัญ

หัวข้อนี้สอนการพัฒนาแบบแตกเป็นรอบสั้น ๆ ที่ทดสอบได้ แทนที่จะมองเป็นงานชิ้นเดียวว่า "สร้างทั้งเกมให้เสร็จ" Rock-paper-scissors ถูกเลือกมาเพราะ domain มันตั้งใจให้ง่าย พอที่จะเปิดพื้นที่ให้เราคุยเรื่องกติกา การตัดสินผลหนึ่งรอบ การเก็บคะแนน และ dependency injection ได้ชัด

สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้

เมื่อจบหัวข้อนี้ คุณควรจะ:

  • แยกเกมเล็ก ๆ ออกเป็น objects ที่มีหน้าที่แคบและชัด
  • สร้างพฤติกรรมเป็น sprint สั้น ๆ แทนที่จะกระโดดทีเดียวเป็น implementation ใหญ่
  • แยกกติกาของ domain ออกจาก flow ของการโต้ตอบ
  • inject แหล่งที่มาของท่า เพื่อให้พฤติกรรมของเกมกำหนดได้ใน tests
  • อธิบายได้ว่าการทำ TDD แบบค่อย ๆ สร้าง เปลี่ยนคุณภาพของการออกแบบอย่างไร

จุดที่ผมใช้ดูความเข้าใจ

ผมอยากให้คุณอธิบายได้ว่าทำไมเกมจึงถูกแยกเป็นเรื่องกติกา เรื่องรอบ และเรื่องแมตช์ แทนที่จะยุบทุกอย่างลงใน script เดียว

โน้ตสั้น

เกมเล็ก ๆ มีประโยชน์ตรงที่กติกาเข้าใจง่าย ผู้เรียนจึงเอาความสนใจไปไว้ที่การตัดสินใจด้าน การออกแบบได้เต็มที่:

  • object ไหนเป็นคนรู้กติกา
  • object ไหนตัดสินผลของหนึ่งรอบ
  • object ไหนเก็บความคืบหน้าข้ามหลายรอบ

หัวข้อนี้ไม่ได้ทำขึ้นเพื่อสร้างเกมที่หวือหวา แต่มันมีไว้เพื่อฝึกการพัฒนาแบบค่อยเป็นค่อยไป ผมอยากให้ผู้เรียนรู้สึกว่าการสร้างทีละชิ้นเล็ก ๆ ช่วยให้ปัญหาง่ายขึ้นจริง

จุดที่ Ruby ทำได้ดีในหัวข้อนี้:

  • domain objects ขนาดเล็กยังอ่านง่าย
  • hashes และ symbols ช่วยให้ผลสรุปของแต่ละรอบเปิดดูได้สะดวก
  • การรับ object ร่วมงานเข้ามาจากภายนอก ทำให้เกมเล่น ๆ ยังทดสอบได้ง่าย

จุดที่ต้องระวังในหัวข้อนี้:

  • โปรเจกต์เล่น ๆ จะกลายเป็น procedural เร็วมาก ถ้าพฤติกรรมทั้งหมดถูกรวมใน class เดียว
  • randomness ควรถูกแยกออก ไม่อย่างนั้น tests จะเปราะ

คำถามชวนคิด:

  • ส่วนไหนของการออกแบบง่ายขึ้น เพราะเรารับแหล่งที่มาของท่าคอมพิวเตอร์เข้ามาจากภายนอก

ตัวอย่างแบบลงมือดู

Example 1: แยกกติกาออกมาก่อน flow ของเกม

มันล่อตาล่อใจมากที่จะเขียน logic แบบ if/else ของเกมทั้งหมดไว้ใน object หลักเลย แต่ในเชิงการสอน วิธีที่ดีกว่าคือแยกกติกาออกมาก่อน:

rules.winner("rock", "scissors")
# => :player_one
worked_examples.md
ruby

เหตุผลที่ตัวอย่างนี้มีประโยชน์:

  • กฎของ domain ถูกทดสอบแยกได้
  • object ที่ตามมาภายหลังพึ่งสัญญาของกติกาที่นิ่งแล้วได้
  • โค้ดส่วน flow ของเกมจึงเล็กลง

Example 2: ทดสอบ game loop แบบกำหนดผลได้

เกมมักมีความสุ่ม แต่ tests ไม่ควรสุ่มไปด้วย

ถ้าท่าของคอมพิวเตอร์มาจาก object ร่วมงานที่ส่งเข้ามาจากภายนอก ชุดทดสอบก็จะควบคุมลำดับได้:

source = instance_double("MoveSource")
allow(source).to receive(:next_move).and_return("scissors", "rock")
worked_examples.md
ruby

นี่คือบทเรียนด้านการออกแบบที่สำคัญที่สุดของหัวข้อนี้: แยกพฤติกรรมที่ไม่นิ่งออกไป

โพยสั้น

ตารางกติกา

WINNING_MOVES = {
  "rock" => "scissors",
  "paper" => "rock",
  "scissors" => "paper"
}.freeze
cheatsheet.md
ruby

สรุปผลของหนึ่งรอบ

{
  player_move: "rock",
  computer_move: "scissors",
  outcome: :win,
  message: "rock beats scissors"
}
cheatsheet.md
ruby

object ร่วมงานที่ส่งเข้ามาจากภายนอก

def initialize(round:, computer_move_source:, target_wins: 2)
  @round = round
  @computer_move_source = computer_move_source
  @target_wins = target_wins
end
cheatsheet.md
ruby

วิธีคิดแบบ sprint

  • Sprint 1: ตัดสินกติกา
  • Sprint 2: สรุปผลหนึ่งรอบ
  • Sprint 3: เก็บคะแนนหลายรอบ
  • Sprint 4: ลองต่อยอดเป็น CLI หรือ UI

คู่มือการเรียน

จุดประสงค์ของหัวข้อนี้

ใช้เกมที่ทุกคนรู้จักเพื่อฝึกการออกแบบแบบค่อย ๆ ต่อเพิ่ม จุดสำคัญไม่ใช่รีบทำโปรเจกต์ให้เสร็จ เร็วที่สุด แต่คือการรู้สึกว่า domain เล็ก ๆ จะง่ายขึ้นอย่างไร เมื่อถูกแตกเป็น sprint สั้น ๆ ที่แต่ละส่วนมีหน้าที่ชัด

ลำดับที่ผมแนะนำ

  1. อ่าน overview.md
  2. อ่าน shortnote.md
  3. อ่าน worked_examples.md
  4. เปิด cheatsheet.md ไว้ตอนอ่าน example.rb
  5. ทำแบบฝึกหัดพื้นฐาน
  6. ทำแบบฝึกหัดขั้นสูง

แผนแบบ sprint

Sprint 1: กติกา

  • ทำความเข้าใจว่าท่าไหนใช้ได้
  • ตัดสินผู้ชนะจากการเจอสองท่า
  • แยก logic นี้ให้อิสระจากส่วนอื่นของเกม

Sprint 2: สรุปผลของหนึ่งรอบ

  • สร้าง object ที่ตัดสินผลของหนึ่งรอบ
  • คืนค่าผลสรุปที่ส่วนอื่นของระบบเอาไปแสดงหรือเก็บต่อได้

Sprint 3: การไหลของทั้งแมตช์

  • เก็บคะแนนข้ามหลายรอบ
  • รับแหล่งที่มาของท่าคอมพิวเตอร์เข้ามาจากภายนอก เพื่อให้ tests ควบคุมได้
  • เลิกคิดแบบ "เกมสุ่ม" แล้วเริ่มคิดแบบ "state transitions ที่ทดสอบได้"

Sprint 4: แนวทางต่อยอด

  • โหมด best-of-five
  • วนรับ input บน command line
  • แหล่งที่มาของท่าแบบอื่น
  • เก็บประวัติแต่ละรอบให้ละเอียดขึ้นสำหรับการรายงานผล

คำถามชวนคิด

  • ทำไม object อย่าง MoveRules ถึงดีกว่าการกระจาย comparisons ไว้ทั่วเกม
  • การออกแบบเปลี่ยนไปอย่างไรเมื่อเริ่มต้องเก็บคะแนน
  • ทำไม dependency injection ถึงยังมีประโยชน์ แม้กับเกมเล่น ๆ

Source Files and Tests

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

# EXAMPLE CODE
# Topic: topic_10_rock_paper_scissors
#
# Purpose:
# - This file demonstrates a reference implementation for the rule engine.
# - It should pass tests from the beginning.
# - Read it before solving the round and match exercises.

class MoveRules
  WINNING_MOVES = {
    "rock" => "scissors",
    "paper" => "rock",
    "scissors" => "paper"
  }.freeze

  def valid_move?(move)
    WINNING_MOVES.key?(move)
  end

  def winner(player_one_move, player_two_move)
    validate_move!(player_one_move)
    validate_move!(player_two_move)

    return :draw if player_one_move == player_two_move

    WINNING_MOVES[player_one_move] == player_two_move ? :player_one : :player_two
  end

  private

  def validate_move!(move)
    raise ArgumentError, "invalid move: #{move}" unless valid_move?(move)
  end
end
en/topic_10_rock_paper_scissors/example.rb
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_10_rock_paper_scissors
#
# What to do:
# - Build a single-round object on top of `MoveRules`.
# - Return a round summary instead of printing directly to the screen.
# - Use the tests as the contract for the object shape.
#
# Expected outcome:
# - You can resolve one round and report the result clearly.

class Round
  def initialize(rules: MoveRules.new)
    @rules = rules
  end

  def play(player_move, computer_move)
    winner = @rules.winner(player_move, computer_move)

    {
      player_move: player_move,
      computer_move: computer_move,
      outcome: outcome_for(winner),
      message: message_for(player_move, computer_move, winner)
    }
  end

  private

  def outcome_for(winner)
    case winner
    when :player_one then :win
    when :player_two then :lose
    else :draw
    end
  end

  def message_for(player_move, computer_move, winner)
    return "draw" if winner == :draw

    winning_move = winner == :player_one ? player_move : computer_move
    losing_move = winner == :player_one ? computer_move : player_move
    "#{winning_move} beats #{losing_move}"
  end
end
en/topic_10_rock_paper_scissors/basic_exercise.rb
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_10_rock_paper_scissors
#
# Academic purpose:
# - Practice iterative design by growing from one round into a small match engine.
# - Show how dependency injection makes a game loop predictable in tests.
#
# Real-world use case:
# - Any stateful workflow with an unstable external input source benefits from this pattern.
# - In a game, the unstable input is randomness.
# - In business code, the unstable input might be time, network data, or an external service.
#
# Why Ruby is beautiful here:
# - The match engine can return simple hashes that are easy to inspect.
# - The injected move source can be any object with `next_move`.
# - The state transitions stay clear without a large framework.
#
# What to do:
# - Track score across turns.
# - Ask the computer move source for the next move each turn.
# - Return enough information for a caller to display the game state.
#
# Expected outcome:
# - Advanced tests pass and you can explain how the object was built in small sprints.

class MatchEngine
  def initialize(round:, computer_move_source:, target_wins: 2)
    @round = round
    @computer_move_source = computer_move_source
    @target_wins = target_wins
    @score = { player: 0, computer: 0 }
  end

  def play_turn(player_move)
    computer_move = @computer_move_source.next_move
    round_summary = @round.play(player_move, computer_move)
    update_score!(round_summary[:outcome])

    round_summary.merge(
      score: @score.dup,
      match_winner: match_winner
    )
  end

  private

  def update_score!(outcome)
    case outcome
    when :win then @score[:player] += 1
    when :lose then @score[:computer] += 1
    end
  end

  def match_winner
    return :player if @score[:player] >= @target_wins
    return :computer if @score[:computer] >= @target_wins

    nil
  end
end
en/topic_10_rock_paper_scissors/adv_exercise.rb
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_10_rock_paper_scissors
#
# Solution idea:
# - Build one object that resolves a single round.
# - Delegate winner detection to `MoveRules`.
# - Return a structured summary so the caller can display or store the result later.

class Round
  def initialize(rules: MoveRules.new)
    @rules = rules
  end

  def play(player_move, computer_move)
    winner = @rules.winner(player_move, computer_move)

    {
      player_move: player_move,
      computer_move: computer_move,
      outcome: outcome_for(winner),
      message: message_for(player_move, computer_move, winner)
    }
  end

  private

  def outcome_for(winner)
    case winner
    when :player_one then :win
    when :player_two then :lose
    else :draw
    end
  end

  def message_for(player_move, computer_move, winner)
    return "draw" if winner == :draw

    winning_move = winner == :player_one ? player_move : computer_move
    losing_move = winner == :player_one ? computer_move : player_move
    "#{winning_move} beats #{losing_move}"
  end
end
en/topic_10_rock_paper_scissors/answer_basic_exercise.rb
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_10_rock_paper_scissors
#
# Solution idea:
# - Keep one `Round` object responsible for single-turn logic.
# - Let `MatchEngine` handle only repeated turns and score tracking.
# - Inject the move source so tests can control the computer's sequence.

class MatchEngine
  def initialize(round:, computer_move_source:, target_wins: 2)
    @round = round
    @computer_move_source = computer_move_source
    @target_wins = target_wins
    @score = { player: 0, computer: 0 }
  end

  def play_turn(player_move)
    computer_move = @computer_move_source.next_move
    round_summary = @round.play(player_move, computer_move)
    update_score!(round_summary[:outcome])

    round_summary.merge(
      score: @score.dup,
      match_winner: match_winner
    )
  end

  private

  def update_score!(outcome)
    case outcome
    when :win then @score[:player] += 1
    when :lose then @score[:computer] += 1
    end
  end

  def match_winner
    return :player if @score[:player] >= @target_wins
    return :computer if @score[:computer] >= @target_wins

    nil
  end
end
en/topic_10_rock_paper_scissors/answer_adv_exercise.rb
Ruby course source
# This spec is your learning companion for topic_10_rock_paper_scissors.
#
# How to use this file:
# 1) Run tests and observe failures or successes.
# 2) Keep the EXAMPLE specs green from the beginning.
# 3) Implement the BASIC exercise as sprint 2: one round.
# 4) Implement the ADVANCED exercise as sprint 3: the match engine.
#
# Expected final result:
# - All examples in this file pass.
# - You understand how to grow a small game through short iterations.

require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"

RSpec.describe "topic_10_rock_paper_scissors" do
  describe "EXAMPLE purpose: understand the rule engine before building the game" do
    it "determines the winner for valid moves" do
      rules = MoveRules.new

      expect(rules.valid_move?("rock")).to eq(true)
      expect(rules.winner("rock", "scissors")).to eq(:player_one)
      expect(rules.winner("paper", "paper")).to eq(:draw)
      expect { rules.winner("lizard", "rock") }.to raise_error(ArgumentError, /invalid move/)
    end
  end

  describe "BASIC EXERCISE purpose: build one round as a small sprint" do
    it "returns a round summary without printing directly" do
      round = Round.new

      expect(round.play("rock", "scissors")).to eq(
        {
          player_move: "rock",
          computer_move: "scissors",
          outcome: :win,
          message: "rock beats scissors"
        }
      )

      expect(round.play("paper", "paper")[:outcome]).to eq(:draw)
    end
  end

  describe "ADVANCED EXERCISE purpose: track a match through iterative turns" do
    it "uses an injected move source to keep the match deterministic" do
      source = instance_double("MoveSource")
      allow(source).to receive(:next_move).and_return("scissors", "rock")

      engine = MatchEngine.new(
        round: Round.new,
        computer_move_source: source,
        target_wins: 2
      )

      first_turn = engine.play_turn("rock")
      second_turn = engine.play_turn("paper")

      expect(first_turn[:score]).to eq({ player: 1, computer: 0 })
      expect(first_turn[:match_winner]).to eq(nil)

      expect(second_turn[:score]).to eq({ player: 2, computer: 0 })
      expect(second_turn[:match_winner]).to eq(:player)
    end
  end
end
en/topic_10_rock_paper_scissors/tests/topic_10_rock_paper_scissors_spec.rb
Ruby course source
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"

exec "${ROOT_DIR}/run_tests.sh" "$(basename "${SCRIPT_DIR}")"
en/topic_10_rock_paper_scissors/run_topic_tests.sh
Ruby course source

Study Prompts

  1. อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise

  2. ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน