Python 进阶实战:倒计时猜数字(面向对象 + 异常处理 + 文件持久化)

在基础猜数字游戏上实现全面升级:用面向对象重构代码结构,加入倒计时压力机制,实现历史记录持久化,覆盖类与对象、属性封装、魔术方法、上下文管理器等核心概念。

如果你已经会写基础版的猜数字游戏,这篇文章将带你把它变成一个真正的软件——有类结构、有状态管理、有持久化存储。这是从"会写代码"到"会写工程"的关键一跃。


目录

  1. 回顾:基础版的局限
  2. 面向对象重构:Game 类设计
  3. 倒计时机制:time 模块与信号处理
  4. 异常处理进阶:自定义异常
  5. 数据持久化:JSON 文件存储
  6. 完整代码实现
  7. 代码走读:关键设计决策
  8. 扩展方向
  9. 学习检查清单

1. 回顾:基础版的局限

我们先整理一下基础版猜数字的问题:

问题说明本版改进
全局变量散放secretattempts 都是全局封装到类属性
无时间压力玩家可以无限思考加入倒计时机制
数据丢失关闭程序记录就没了存到 JSON 文件
代码难以扩展加减功能要改多处模块化设计
单一场景只有一种玩法多难度 + 限时模式

2. 面向对象重构:Game 类设计

2.1 类的职责划分

from dataclasses import dataclass, field
from typing import List, Optional
import random
import time

@dataclass
class GameRecord:
    """单局游戏记录"""
    player_name: str
    difficulty: str
    attempts_used: int
    max_attempts: int
    time_used: float
    won: bool
    timestamp: float = field(default_factory=time.time)
    
    def to_dict(self) -> dict:
        return {
            "player": self.player_name,
            "difficulty": self.difficulty,
            "attempts": f"{self.attempts_used}/{self.max_attempts}",
            "time": f"{self.time_used:.1f}s",
            "result": "🏆 胜利" if self.won else "💀 失败",
            "date": time.strftime("%Y-%m-%d %H:%M", time.localtime(self.timestamp)),
        }


class GuessNumberGame:
    """猜数字游戏核心类"""
    
    # 类常量:游戏配置
    CONFIGS = {
        "easy":   {"range": (1, 50),   "attempts": 10, "time_limit": 120},
        "normal": {"range": (1, 100),  "attempts": 7,  "time_limit": 90},
        "hard":   {"range": (1, 200),  "attempts": 5,  "time_limit": 60},
        "insane": {"range": (1, 500),  "attempts": 3,  "time_limit": 30},
    }
    
    def __init__(self, player_name: str = "匿名玩家"):
        self.player_name = player_name
        self.records: List[GameRecord] = []
        self._current_game: Optional[dict] = None
    
    def start_game(self, difficulty: str = "normal") -> "GameSession":
        """开始新游戏,返回一个游戏会话"""
        if difficulty not in self.CONFIGS:
            raise ValueError(f"未知难度: {difficulty}。可选: {list(self.CONFIGS.keys())}")
        
        config = self.CONFIGS[difficulty]
        return GameSession(
            player=self.player_name,
            difficulty=difficulty,
            min_val=config["range"][0],
            max_val=config["range"][1],
            max_attempts=config["attempts"],
            time_limit=config["time_limit"],
        )
    
    def add_record(self, session: "GameSession"):
        """添加一局记录"""
        record = GameRecord(
            player_name=self.player_name,
            difficulty=session.difficulty,
            attempts_used=session.attempts_used,
            max_attempts=session.max_attempts,
            time_used=session.time_used,
            won=session.won,
        )
        self.records.append(record)
    
    def get_stats(self) -> dict:
        """获取统计数据"""
        if not self.records:
            return {"message": "暂无记录"}
        
        wins = sum(1 for r in self.records if r.won)
        total = len(self.records)
        avg_attempts = sum(r.attempts_used for r in self.records) / total
        
        return {
            "总局数": total,
            "胜利": wins,
            "胜率": f"{wins/total*100:.1f}%",
            "平均尝试": f"{avg_attempts:.1f} 次",
        }


class GameSession:
    """单局游戏会话"""
    
    def __init__(self, player, difficulty, min_val, max_val, 
                 max_attempts, time_limit):
        self.player = player
        self.difficulty = difficulty
        self.min_val = min_val
        self.max_val = max_val
        self.max_attempts = max_attempts
        self.time_limit = time_limit  # 秒
        
        self.secret = random.randint(min_val, max_val)
        self.attempts_used = 0
        self.guesses: List[int] = []
        self.start_time: Optional[float] = None
        self.end_time: Optional[float] = None
        self.won = False
        
        # 运行时状态
        self._aborted = False
    
    @property
    def time_used(self) -> float:
        if self.start_time is None:
            return 0.0
        end = self.end_time or time.time()
        return end - self.start_time
    
    @property
    def time_remaining(self) -> float:
        return max(0, self.time_limit - self.time_used)
    
    @property
    def is_time_up(self) -> bool:
        return self.time_remaining <= 0
    
    @property
    def is_game_over(self) -> bool:
        return self.won or self.attempts_used >= self.max_attempts or self.is_time_up
    
    def make_guess(self, guess: int) -> str:
        """进行一次猜测,返回结果提示"""
        if self.start_time is None:
            self.start_time = time.time()
        
        if self.is_game_over:
            return "游戏已结束"
        
        if guess < self.min_val or guess > self.max_val:
            raise OutOfRangeError(f"数字必须在 {self.min_val}{self.max_val} 之间")
        
        self.attempts_used += 1
        self.guesses.append(guess)
        
        if guess == self.secret:
            self.won = True
            self.end_time = time.time()
            return "correct"
        elif guess < self.secret:
            return "too_low"
        else:
            return "too_high"
    
    def get_hint(self) -> str:
        """基于历史猜测给出智能提示"""
        if not self.guesses:
            return f"范围: {self.min_val} ~ {self.max_val}"
        
        # 过滤出有效范围
        low_bound = self.min_val
        high_bound = self.max_val
        
        for g in self.guesses:
            if g < self.secret and g > low_bound:
                low_bound = g
            elif g > self.secret and g < high_bound:
                high_bound = g
        
        return f"提示: {low_bound} < 答案 < {high_bound}"
    
    def __repr__(self) -> str:
        return (f"GameSession(difficulty={self.difficulty}, "
                f"attempts={self.attempts_used}/{self.max_attempts}, "
                f"time={self.time_used:.1f}s/{self.time_limit}s, "
                f"won={self.won})")


# 自定义异常
class OutOfRangeError(ValueError):
    """猜测超出范围"""
    pass

class TimeExpiredError(RuntimeError):
    """时间到"""
    pass

3. 倒计时机制:time 模块与信号处理

3.1 基础倒计时实现

import time
import sys

def countdown(seconds: int):
    """简单倒计时"""
    for remaining in range(seconds, 0, -1):
        sys.stdout.write(f"\r⏱ 剩余时间: {remaining:2d} 秒")
        sys.stdout.flush()
        time.sleep(1)
    print("\n⏰ 时间到!")

3.2 非阻塞倒计时(用于猜数字)

在猜数字游戏中,我们需要玩家输入和倒计时同时进行,不能阻塞:

import time
import threading

class CountdownTimer:
    """倒计时器:在游戏进行时显示剩余时间"""
    
    def __init__(self, total_seconds: int, callback=None):
        self.total = total_seconds
        self.remaining = total_seconds
        self._running = False
        self._callback = callback
        self._thread: Optional[threading.Thread] = None
    
    def start(self):
        self._running = True
        self.start_time = time.time()
        self._thread = threading.Thread(target=self._tick)
        self._thread.start()
    
    def _tick(self):
        while self._running and self.remaining > 0:
            elapsed = time.time() - self.start_time
            self.remaining = max(0, self.total - int(elapsed))
            
            if self.remaining <= 5:
                print(f"\r⚠️ 剩余 {self.remaining} 秒!", end="", flush=True)
            
            time.sleep(0.5)
        
        if self.remaining == 0 and self._callback:
            self._callback()
    
    def stop(self):
        self._running = False
        if self._thread:
            self._thread.join(timeout=1)
    
    @property
    def is_expired(self) -> bool:
        return self.remaining <= 0

⚠️ 在终端环境中,多线程倒计时与 input() 同时运行会有显示冲突。生产环境更推荐使用 curses 库或 Web 界面。


4. 异常处理进阶:自定义异常

# 定义游戏的异常体系
class GameError(Exception):
    """游戏基础异常"""
    pass

class InvalidInputError(GameError):
    """输入格式错误"""
    def __init__(self, user_input: str):
        self.user_input = user_input
        super().__init__(f"'{user_input}' 不是有效的数字")

class OutOfRangeError(GameError):
    """数字超出范围"""
    pass

class TimeExpiredError(GameError):
    """时间耗尽"""
    pass

class GameAlreadyEndedError(GameError):
    """游戏已结束,无法继续操作"""
    pass

# 使用示例
def safe_get_input(session: GameSession) -> int:
    """安全获取玩家输入"""
    user_input = input("\n输入数字 (或 'hint' 获取提示, 'quit' 退出): ").strip()
    
    if user_input.lower() == "quit":
        raise GameAlreadyEndedError("玩家主动退出")
    
    if user_input.lower() == "hint":
        print(f"💡 {session.get_hint()}")
        raise InvalidInputError("hint")   # 不算有效猜测,但不算真正错误
    
    try:
        return int(user_input)
    except ValueError:
        raise InvalidInputError(user_input)

5. 数据持久化:JSON 文件存储

import json
from pathlib import Path
from typing import List

class RecordManager:
    """记录管理器:处理存档文件读写"""
    
    def __init__(self, filepath: str = None):
        if filepath is None:
            filepath = Path.home() / ".guess_number_records.json"
        self.filepath = Path(filepath)
    
    def save(self, records: List[GameRecord]):
        """保存记录到文件"""
        data = [r.to_dict() for r in records]
        self.filepath.write_text(
            json.dumps(data, ensure_ascii=False, indent=2),
            encoding="utf-8"
        )
    
    def load(self) -> List[dict]:
        """从文件加载记录"""
        if not self.filepath.exists():
            return []
        try:
            text = self.filepath.read_text(encoding="utf-8")
            return json.loads(text)
        except json.JSONDecodeError:
            return []
    
    def export_stats(self) -> str:
        """导出统计摘要"""
        records = self.load()
        if not records:
            return "暂无记录"
        
        wins = sum(1 for r in records if "胜利" in r.get("result", ""))
        lines = [
            f"总游戏数: {len(records)}",
            f"胜利次数: {wins}",
            f"胜率: {wins/len(records)*100:.1f}%",
        ]
        return "\n".join(lines)

6. 完整代码实现

#!/usr/bin/env python3
"""
增强版倒计时猜数字游戏
功能:面向对象设计、倒计时机制、记录持久化、多难度模式
"""

import random
import time
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Optional


# ========== 异常定义 ==========

class GameError(Exception):
    """游戏基础异常"""
    pass

class InvalidInputError(GameError):
    def __init__(self, user_input: str, message: str = None):
        self.user_input = user_input
        msg = message or f"'{user_input}' 不是有效的整数"
        super().__init__(msg)


# ========== 数据模型 ==========

@dataclass
class GameRecord:
    player: str
    difficulty: str
    attempts_used: int
    max_attempts: int
    time_used: float
    won: bool
    timestamp: float = field(default_factory=time.time)
    
    def to_dict(self) -> dict:
        return {
            "player": self.player,
            "difficulty": self.difficulty,
            "attempts": f"{self.attempts_used}/{self.max_attempts}",
            "time": f"{self.time_used:.1f}s",
            "result": "🏆 胜利" if self.won else "💀 失败",
            "date": time.strftime("%Y-%m-%d %H:%M", time.localtime(self.timestamp)),
        }


class GameSession:
    """单局游戏会话"""
    
    def __init__(self, player: str, difficulty: str,
                 min_val: int, max_val: int,
                 max_attempts: int, time_limit: int):
        self.player = player
        self.difficulty = difficulty
        self.min_val = min_val
        self.max_val = max_val
        self.max_attempts = max_attempts
        self.time_limit = time_limit
        
        self.secret = random.randint(min_val, max_val)
        self.attempts_used = 0
        self.guesses: List[int] = []
        self.start_time: Optional[float] = None
        self.end_time: Optional[float] = None
        self.won = False
    
    @property
    def time_used(self) -> float:
        end = self.end_time or time.time()
        start = self.start_time or end
        return end - start
    
    @property
    def time_remaining(self) -> float:
        return max(0, self.time_limit - self.time_used)
    
    @property
    def is_game_over(self) -> bool:
        return self.won or self.attempts_used >= self.max_attempts or self.time_remaining <= 0
    
    def make_guess(self, guess: int) -> str:
        if self.start_time is None:
            self.start_time = time.time()
        
        if self.is_game_over:
            return "game_over"
        
        if not (self.min_val <= guess <= self.max_val):
            return "out_of_range"
        
        self.attempts_used += 1
        self.guesses.append(guess)
        
        if guess == self.secret:
            self.won = True
            self.end_time = time.time()
            return "correct"
        elif guess < self.secret:
            return "too_low"
        else:
            return "too_high"
    
    def get_hint(self) -> str:
        low = max(g for g in self.guesses + [self.min_val - 1] if g < self.secret) + 1
        high = min(g for g in self.guesses + [self.max_val + 1] if g > self.secret) - 1
        return f"答案在 {low}{high} 之间"


class RecordManager:
    """记录持久化管理"""
    
    FILEPATH = Path.home() / ".guess_number_records.json"
    
    @classmethod
    def save(cls, records: List[GameRecord]):
        data = [r.to_dict() for r in records]
        cls.FILEPATH.write_text(json.dumps(data, ensure_ascii=False, indent=2),
                                encoding="utf-8")
    
    @classmethod
    def load(cls) -> List[dict]:
        if not cls.FILEPATH.exists():
            return []
        try:
            return json.loads(cls.FILEPATH.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, IOError):
            return []


class GameEngine:
    """游戏引擎"""
    
    CONFIGS = {
        "easy":   {"range": (1, 50),   "attempts": 10, "time": 120},
        "normal": {"range": (1, 100),  "attempts": 7,  "time": 90},
        "hard":   {"range": (1, 200),  "attempts": 5,  "time": 60},
    }
    
    def __init__(self):
        self.records: List[GameRecord] = []
        self._load_records()
    
    def _load_records(self):
        loaded = RecordManager.load()
        # 简化:只显示,不复建 GameRecord 对象
    
    def select_difficulty(self) -> str:
        print("\n选择难度:")
        for key, cfg in self.CONFIGS.items():
            print(f"  [{key}] {cfg['range'][0]}~{cfg['range'][1]}, "
                  f"{cfg['attempts']}次机会, {cfg['time']}秒")
        
        while True:
            choice = input("输入 (easy/normal/hard): ").strip().lower()
            if choice in self.CONFIGS:
                return choice
            print("无效选择,请重试")
    
    def play(self):
        difficulty = self.select_difficulty()
        cfg = self.CONFIGS[difficulty]
        
        player = input("\n你的昵称: ").strip() or "匿名"
        
        session = GameSession(
            player=player, difficulty=difficulty,
            min_val=cfg["range"][0], max_val=cfg["range"][1],
            max_attempts=cfg["attempts"], time_limit=cfg["time"]
        )
        
        print(f"\n{'='*50}")
        print(f"🎮 难度: {difficulty} | 范围: {cfg['range']} | "
              f"时限: {cfg['time']}秒")
        print(f"{'='*50}")
        
        while not session.is_game_over:
            print(f"\n{session.attempts_used + 1}/{session.max_attempts} 次 | "
                  f"剩余时间: {session.time_remaining:.0f}秒")
            
            user_input = input("输入数字 (hint/quit): ").strip().lower()
            
            if user_input == "quit":
                print("👋 已退出")
                return
            
            if user_input == "hint":
                print(f"💡 {session.get_hint()}")
                continue
            
            try:
                guess = int(user_input)
            except ValueError:
                print("⚠️ 请输入整数!")
                continue
            
            result = session.make_guess(guess)
            
            if result == "out_of_range":
                print(f"⚠️ 超出范围!请输入 {session.min_val}~{session.max_val}")
                continue
            
            if result == "correct":
                print(f"\n🎉 恭喜 {player}!猜对了!")
                print(f"🎯 答案就是 {session.secret}!")
                print(f"🏆 用时: {session.time_used:.1f}s | "
                      f"尝试: {session.attempts_used} 次")
                break
            elif result == "too_low":
                print("📈 太小了!")
            elif result == "too_high":
                print("📉 太大了!")
            
            print(f"💡 提示: {session.get_hint()}")
        
        if not session.won:
            print(f"\n💀 游戏结束!正确答案是 {session.secret}")
            print(f"⏱ 用时: {session.time_used:.1f}s")
        
        # 保存记录
        record = GameRecord(
            player=player, difficulty=difficulty,
            attempts_used=session.attempts_used,
            max_attempts=session.max_attempts,
            time_used=session.time_used, won=session.won
        )
        self.records.append(record)
        RecordManager.save(self.records)
    
    def show_stats(self):
        records = RecordManager.load()
        if not records:
            print("\n暂无记录")
            return
        
        print(f"\n{'='*50}")
        print("📊 历史记录")
        print(f"{'='*50}")
        
        for r in records[-10:]:   # 最近 10 条
            print(f"  {r['date']} | {r['player']} | "
                  f"{r['difficulty']} | {r['attempts']} | "
                  f"{r['time']} | {r['result']}")
        
        wins = sum(1 for r in records if "胜利" in r.get("result", ""))
        print(f"\n总结: {len(records)} 局 | 胜 {wins} 局 | "
              f"胜率 {wins/len(records)*100:.1f}%")
    
    def run(self):
        print("="*50)
        print("🎮 增强版倒计时猜数字")
        print("="*50)
        
        while True:
            print("\n1. 开始游戏")
            print("2. 历史记录")
            print("3. 退出")
            
            choice = input("\n选择: ").strip()
            
            if choice == "1":
                self.play()
            elif choice == "2":
                self.show_stats()
            elif choice == "3":
                print("👋 再见!")
                break
            else:
                print("无效选择")


if __name__ == "__main__":
    GameEngine().run()

7. 代码走读:关键设计决策

7.1 为什么选择 dataclass?

# 传统写法需要手写 __init__、__repr__ 等
# dataclass 自动生成这些,减少样板代码

@dataclass
class GameRecord:
    player: str
    difficulty: str
    attempts_used: int
    # ... 自动获得 __init__, __repr__, __eq__

7.2 为什么用 classmethod 管理文件?

class RecordManager:
    FILEPATH = Path.home() / ".guess_number_records.json"
    
    @classmethod
    def save(cls, records):
        # 不依赖实例,直接通过类调用
        # RecordManager.save(records)

这样 RecordManager 更像一个命名空间 + 工具集,不需要实例化。

7.3 属性(@property)的妙用

@property
def time_remaining(self) -> float:
    return max(0, self.time_limit - self.time_used)

# 使用时像访问属性一样读取
print(session.time_remaining)   # 自动计算,不需要 ()

8. 扩展方向

  1. 图形界面:用 tkinterPyQt 做 GUI 版
  2. Web 版本:用 Flask/FastAPI 做在线对战
  3. AI 对手:实现自动二分查找玩家
  4. 多人排行榜:数据库存储 + Web 接口
  5. 单元测试:用 pytest 测试 GameSession 逻辑

9. 学习检查清单

完成后检查你是否掌握了:

  • @dataclass 的用途和使用场景
  • @classmethod vs @staticmethod
  • @property 装饰器的作用
  • 类的封装思想(private _attr
  • 自定义异常的继承体系
  • JSON 文件读写操作
  • time.time() 计时原理
  • typing 类型注解的基本用法

延伸阅读


🎯 核心收获:通过这个项目,你体验到了"写脚本"和"写软件"的区别。类封装了状态,方法封装了行为,文件持久化让数据跨越进程生命周期——这才是工程化编程的起点。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. Python 高级异步编程:Trio 结构化并发与 AnyIO 兼容层
  2. Python 数据工程与 ETL 管道实战
  3. Python 元编程与动态特性深度解析