สร้าง 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_oneruby
เหตุผลที่ตัวอย่างนี้มีประโยชน์:
- กฎของ domain ถูกทดสอบแยกได้
- object ที่ตามมาภายหลังพึ่งสัญญาของกติกาที่นิ่งแล้วได้
- โค้ดส่วน flow ของเกมจึงเล็กลง
Example 2: ทดสอบ game loop แบบกำหนดผลได้
เกมมักมีความสุ่ม แต่ tests ไม่ควรสุ่มไปด้วย
ถ้าท่าของคอมพิวเตอร์มาจาก object ร่วมงานที่ส่งเข้ามาจากภายนอก ชุดทดสอบก็จะควบคุมลำดับได้:
source = instance_double("MoveSource")
allow(source).to receive(:next_move).and_return("scissors", "rock")ruby
นี่คือบทเรียนด้านการออกแบบที่สำคัญที่สุดของหัวข้อนี้: แยกพฤติกรรมที่ไม่นิ่งออกไป
โพยสั้น
ตารางกติกา
WINNING_MOVES = {
"rock" => "scissors",
"paper" => "rock",
"scissors" => "paper"
}.freezeruby
สรุปผลของหนึ่งรอบ
{
player_move: "rock",
computer_move: "scissors",
outcome: :win,
message: "rock beats scissors"
}ruby
object ร่วมงานที่ส่งเข้ามาจากภายนอก
def initialize(round:, computer_move_source:, target_wins: 2)
@round = round
@computer_move_source = computer_move_source
@target_wins = target_wins
endruby
วิธีคิดแบบ sprint
- Sprint 1: ตัดสินกติกา
- Sprint 2: สรุปผลหนึ่งรอบ
- Sprint 3: เก็บคะแนนหลายรอบ
- Sprint 4: ลองต่อยอดเป็น CLI หรือ UI
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
ใช้เกมที่ทุกคนรู้จักเพื่อฝึกการออกแบบแบบค่อย ๆ ต่อเพิ่ม จุดสำคัญไม่ใช่รีบทำโปรเจกต์ให้เสร็จ เร็วที่สุด แต่คือการรู้สึกว่า domain เล็ก ๆ จะง่ายขึ้นอย่างไร เมื่อถูกแตกเป็น sprint สั้น ๆ ที่แต่ละส่วนมีหน้าที่ชัด
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.md - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐาน
- ทำแบบฝึกหัดขั้นสูง
แผนแบบ 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
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
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
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
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
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
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}")"
Ruby course source
Study Prompts
อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise
ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน