news 2026/9/16 14:45:31

基于 Ruby 面向对象编程:命令行井字棋(Tic Tac Toe)项目实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 Ruby 面向对象编程:命令行井字棋(Tic Tac Toe)项目实战指南

基于 Ruby 面向对象编程:命令行井字棋(Tic Tac Toe)项目实战指南

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

导读

本篇技术指南围绕本项目(The Odin Project 开源课程仓库)中 Ruby 面向对象编程基础章节的井字棋项目作业展开,目标是引导你用刚学到的 OOP(面向对象编程)能力,在命令行中构建一个双人互搏、每回合刷新棋盘显示的井字棋游戏。读完本文,你将掌握如何拆分类 / 实例变量 / 方法、如何通过类的职责划分与信息隔离组织游戏逻辑,以及如何用require_relative组织多文件 Ruby 项目,为后续 Mastermind、Hangman 乃至 Chess 等更复杂的 OOP 项目打下基础。

为什么是井字棋:最适合练习 OOP 的“小问题”

井字棋(Tic Tac Toe,又名 Noughts and Crosses)规则极其简单:两名玩家轮流在 3×3 棋盘上落子,率先在横、竖、斜任一方向连成三子者获胜。正如课程在 Ruby 课程总览中所说,通过这门课程你将亲手构建Tic Tac Toe、Hangman、Chess等项目,把“意大利面条式的代码”拆分成清晰独立的类。

井字棋之所以被选为 OOP 章节的第一个项目,是因为它天然蕴含了 OOP 的经典要素:

  • 若干参与者:两个玩家(Player);
  • 一个共享状态:棋盘(Board);
  • 不断重复的游戏循环:轮流落子 → 刷新显示 → 判定胜负(game loop)。

“几名玩家、一块棋盘、在游戏循环中检查胜利”——这些条件凑在一起,恰好构成了一个可以用类(class)、实例变量(instance variable)和方法(method)来优雅建模的小型问题。正如 面向对象编程课程 强调的:DRY(不要重复自己)、模块化、让类和方法只做一件事、尽量少地向外界暴露接口、不要让方法或类彼此重度依赖——这些原则在这个项目里都会得到充分练习。

项目任务要求

本项目位于 ruby/object_oriented_programming_basics/project_tic_tac_toe.md,核心任务如下:

在命令行构建一个井字棋游戏,两名人类玩家可以互相对战,并且棋盘要在每回合之间显示出来。

具体的作业步骤是:

  1. 先想后写:思考你会如何搭建游戏中的不同元素……什么是类?什么是实例变量?什么是方法?花几分钟思考,可以避免浪费一小时的编码时间。
  2. 动手构建:构建你的游戏,注意不要在不必要的情况下在类之间共享信息。
  3. 提交与对照:提交你的解决方案,然后对照参考实现检查自己的设计。

这套“先设计 → 再实现 → 再对照”的流程,与同章节 Mastermind 项目的“先想清楚怎么搭,再写代码”的要求一脉相承,目的就是强迫你在写代码前先做对象建模

动手前的设计思考:什么是类、实例变量、方法

原文档要求你在动手前思考:“什么是类?什么是实例变量?什么是方法?”。这是整个项目成败的关键一步。针对井字棋,我们逐一分析:

类的划分

井字棋世界里的核心“名词”就是天然的对象候选:

候选类职责说明
Board(棋盘)存储 3×3 的格子状态、渲染棋盘、判断是否满盘游戏的“共享状态”所在
Player(玩家)持有玩家标记(X / O)与姓名极简类,主要承载数据
Game(游戏)控制回合流转、接收落子输入、调用胜负判定游戏循环的主控者

判断一个“名词”是否值得成为类,可以参考课程 managing_ruby_projects.md 的约定:每个类一个文件。如果某个概念有自己独立的职责和数据,就值得拆成一个类;如果它只是别的方法里的一个临时值,就应该是一个局部变量。

实例变量的划分

实例变量(@variable)承载的是对象的持久状态,需要区分“谁拥有这份数据”:

  • 棋盘上的格子状态数组@grid(或@board)属于Board
  • 玩家使用的标记符号@marker"X"/"O")属于Player
  • 当前轮到谁、游戏是否结束这类流程状态属于Game

判断标准很简单:这份数据会被谁反复读取和修改?就把它放进谁里。棋盘格子只被棋盘渲染和胜负判定读取,所以它属于Board;而“轮到谁”由游戏循环控制,所以它属于Game

方法的划分

方法的本质是“行为”,要遵循课程强调的单一职责

  • Board#display:把棋盘打印到终端;
  • Board#update/Board#place_marker:在某格落子;
  • Board#full?:判断棋盘是否已满(平局条件);
  • Game#win?/Board#winning_line?:判断是否存在三连;
  • Game#play:启动并驱动整个游戏循环。

关于胜负判定逻辑放在Board还是Game,两种设计都合理:如果你认为“某标记是否形成三连”是棋盘自身的属性(棋盘知道自己上面发生了什么),就放进Board;如果你认为它是“游戏规则”的一部分,就放进Game。关键是选定一处并保持一致,避免同一逻辑在两个类里重复出现。

参考实现骨架:类与方法的组织方式

结合上述设计,下面给出一份可运行的参考骨架。它遵循“先想后写”的原则,把棋盘、玩家、游戏三个关注点分离。首先是棋盘类:

# lib/board.rb class Board WINNING_LINES = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], # 横 [0, 3, 6], [1, 4, 7], [2, 5, 8], # 竖 [0, 4, 8], [2, 4, 6] # 斜 ].freeze def initialize @grid = Array.new(9, ' ') end def display puts " #{@grid[0]} | #{@grid[1]} | #{@grid[2]} " puts '---+---+---' puts " #{@grid[3]} | #{@grid[4]} | #{@grid[5]} " puts '---+---+---' puts " #{@grid[6]} | #{@grid[7]} | #{@grid[8]} " end def place_marker(position, marker) return false unless valid_move?(position) @grid[position] = marker true end def valid_move?(position) position.between?(0, 8) && @grid[position] == ' ' end def full? @grid.none? { |cell| cell == ' ' } end def winner?(marker) WINNING_LINES.any? { |line| line.all? { |index| @grid[index] == marker } } end end

注意WINNING_LINES被定义为常量并freeze,因为它是所有棋盘实例共享、且永不改变的规则数据;而@grid是实例变量,因为每局游戏的棋盘状态都不同。这与课程在 object_oriented_programming.md 中讲解的“实例变量与类变量的区别”知识点直接呼应。

玩家类只需要极简地持有标记与姓名:

# lib/player.rb class Player attr_reader :name, :marker def initialize(name, marker) @name = name @marker = marker end end

游戏类负责“编排”,它持有两个玩家和一块棋盘,通过循环驱动回合:

# lib/game.rb class Game def initialize(player1, player2) @board = Board.new @players = [player1, player2] @current_player = @players.first end def play loop do @board.display position = ask_for_move @board.place_marker(position, @current_player.marker) break if game_over? switch_turns end announce_result end private def ask_for_move print "#{@current_player.name}(#{@current_player.marker}),请选择空格编号 0-8:" gets.chomp.to_i end def switch_turns @current_player = @current_player == @players.first ? @players.last : @players.first end def game_over? @board.full? || @board.winner?(@current_player.marker) end def announce_result @board.display if @board.winner?(@current_player.marker) puts "#{@current_player.name} 获胜!" else puts '平局!' end end end

最后是入口文件,只负责装配并启动:

# main.rb require_relative 'lib/board' require_relative 'lib/player' require_relative 'lib/game' player1 = Player.new('玩家一', 'X') player2 = Player.new('玩家二', 'O') Game.new(player1, player2).play

运行方式(在项目根目录的终端中):

ruby main.rb

类间信息隔离:不共享比共享更重要

原文档第二条要求是:“注意不要在不必要的情况下在类之间共享信息(taking care to not share information between classes any more than you have to)”。这正是 面向对象编程课程 中“尽量少地向世界展示你的接口”“不要让方法或类重度依赖彼此”的具体实践。

对照上面的骨架,可以总结出三条“信息隔离”守则:

  1. 通过方法而非裸数据交互Game不直接读写@board的内部数组,而是调用Board#place_markerBoard#winner?Board#full?。这样即使把@grid的实现从数组改成哈希,Game也无需改动。
  2. 让数据待在“主人”那里:格子状态归Board、标记归Player、流程状态归Game。不要把棋盘数组塞进Game,也不要在Player里保存棋盘——谁拥有这份状态,谁才应该读写它。
  3. 用只读访问器控制暴露面Player只通过attr_reader暴露namemarker,没有任何可写接口,外部无法篡改玩家身份。如果某份数据对外只读,就只提供 reader。

一个常见的反面示例是:让Game直接操作一个全局数组、或者在Player里内置对Board的依赖。这类写法虽然能跑通,但在代码量增长后(例如扩展到 Mastermind、Connect Four)会迅速变得难以维护。本项目正是为“类之间保持低耦合”建立肌肉记忆的关键训练。

多文件组织:用 require_relative 组装项目

课程在 managing_ruby_projects.md 中强调了两条 Ruby 项目组织惯例:

  • 每个类一个文件
  • 把所有 Ruby 源文件放进lib目录,根目录保留一个入口文件(如main.rb)。

井字棋项目的推荐目录结构如下:

tic_tac_toe ├── lib │ ├── board.rb │ ├── player.rb │ └── game.rb └── main.rb

跨文件引用自己写的代码时,使用require_relative。它的语义是:相对于当前文件所在目录查找被引用的文件(.rb后缀可省略),与你在哪个目录下执行命令无关。因此main.rb里写require_relative 'lib/board'lib/game.rb里写require_relative 'player'require_relative 'board'(因为game.rb本身就在lib目录中)即可。

# lib/game.rb require_relative 'board' require_relative 'player'

需要特别留意的是:require(不带_relative)解析相对路径时是以当前工作目录为基准的,容易因运行位置不同而报LoadError。所以对项目自有代码统一使用require_relative,对标准库和 gem 才使用require(例如require 'csv')。此外,被 require 的代码会进入同一命名空间,重名的方法/常量会相互覆盖——这是把代码拆到多个文件后必须记住的潜在陷阱。

用 RuboCop 给井字棋做一次“代码体检”

完成基本功能后,Linting 与 RuboCop 课程建议你对自己的 Ruby 项目运行 RuboCop 检查。井字棋是多文件、多类的第一个 OOP 项目,正是体会 Metrics(指标)类 Cop 价值的最佳时机:

  • Metrics/AbcSize:衡量方法中的赋值(Assignment)、分支调用(Branch)、条件(Conditional)规模。如果你写出了“又长又绕”的方法(比如把落子、判胜、切回合全写在一个方法里),RuboCop 会提示你拆分——这正是重构的信号;
  • Metrics/CyclomaticComplexity:统计方法可走的路径数。if/elsif、逻辑运算符、循环都会累加,提醒你控制分支复杂度;
  • Metrics/PerceivedComplexity:进一步对控制流加权,衡量人类阅读的困难程度。

在项目根目录运行:

bundle exec rubocop

如果提示Style/StringLiterals之类的风格问题,可以用bundle exec rubocop -a做安全自动修正;对于确实需要保留的复杂方法,才使用行内注释# rubocop:disable Metrics/...临时豁免。RuboCop 与 Ruby LSP 集成后,还会在 VSCode 的Problems面板实时显示问题,并支持针对单行代码的 Quickfix。

提交与后续:从井字棋到测试驱动的 Connect Four

完成实现并提交后,井字棋项目在课程线中还有后续延伸:

  1. 为井字棋编写测试:在 Ruby 测试与 RSpec 章节中,你会回到这个项目,用 RSpec 为它补写测试——例如“棋盘第一行是 X X X 时,#game_over(或等价方法)应判定玩家获胜”,并使用 mock/double 隔离方法、验证返回值。因此现在设计类和方法时,就要让每个方法有清晰的单一职责和可预测的返回值,这会让你写测试时省力得多。这与 JS 课程线中的 浏览器版井字棋项目(要求先实现控制台版本、再接入 DOM)在设计哲学上完全一致:先让核心逻辑在纯命令行/纯数据结构层面跑通
  2. 下一个项目是 Mastermind:同章节的 Mastermind 项目要求构建 12 回合内猜中密令的游戏,并支持人机互换角色、为电脑实现猜谜策略——它建立在井字棋练就的类划分与状态管理能力之上。
  3. 进阶是 Connect Four:在 Connect Four 项目中,你将以TDD(测试驱动开发)方式重构命令行游戏——先写失败测试、再写最小实现、再重构。井字棋中学到的“棋盘状态归 Board、流程归 Game、玩家只持数据”的模型,可以直接迁移到 7 列 6 行的棋盘上。

常见坑点与自查清单

构建过程中,下面的自查清单可以帮助你对照原文档的验收标准:

  • 棋盘是否在每回合之间正确显示(而不是只在结束时显示一次)?
  • 两名人类玩家是否轮流落子,且使用的标记不同(X / O)?
  • 是否禁止在已被占用的格子落子?(valid_move?需要正确处理边界)
  • 横、竖、斜三种获胜方向是否都被覆盖?平局(棋盘满且无人获胜)是否被正确处理?
  • Game是否通过Board的方法读写棋盘,而不是直接操作内部数组?
  • 是否每个类一个文件,且用require_relative正确引用?
  • 是否能用bundle exec rubocop通过(或至少理解每一条 offense)?

一个值得注意的设计细节是输入坐标的约定:参考骨架使用 0-8 的一维索引对应 3×3 棋盘,而许多实现会采用 1-9 的编号或(row, col)二维坐标。无论选哪种,都应把“位置表示法”限定在Board内部(或统一约定),避免GameBoard各自维护一套坐标解释逻辑——这也是“不共享不必要信息”原则的延伸。

总结

本项目的价值不在“写出一个能玩的井字棋”,而在于完成一次完整的对象建模 → 分文件实现 → 低耦合组装 → 代码体检流程:通过思考“什么是类、什么是实例变量、什么是方法”,你把一个熟悉的游戏拆解为BoardPlayerGame三个职责清晰的类;通过require_relative组织多文件结构,体验了真实 Ruby 项目的布局惯例;通过信息隔离原则,让类之间只通过方法协作。这套能力将直接复用到 Mastermind、Hangman、Connect Four,并最终在 Chess 项目中迎来全面检验——正如课程所说,你会“开始感觉更像一个真正的程序员,而且这种感觉是名副其实的”。

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 14:45:02

微信小程序超市购物系统代码使用指南:从解压到跑通全流程

简介:这套基于微信小程序的超市购物系统代码,面向正在学习小程序开发、需要完成课程设计或搭建线上购物场景的开发者与研究者。系统覆盖商品浏览、搜索、购物车、订单生成等核心流程,并涉及用户管理、支付接口、订单跟踪等扩展特性&#xff0…

作者头像 李华
网站建设 2026/9/16 14:41:06

GC3909S一芯双驱:中小功率运动控制的集成化新范式

1. 为什么这颗“一芯双驱”的GC3909S正在悄悄改变中小功率运动控制的底层逻辑你有没有遇到过这样的场景:给一台桌面级3D打印机加装第二路Z轴同步升降,结果发现主控板上那块DRV8825已经占满IO口,再塞一块就得改PCB;或者调试一台轻型…

作者头像 李华