news 2026/10/20 5:23:54

用ASCII终端实现确定性像素物理引擎

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用ASCII终端实现确定性像素物理引擎

简介:这是一份面向JavaScript初学者与前端入门学习者的轻量级网页游戏开发实践资源,聚焦于用原生JS实现经典《宝石迷阵》(Boulder Dash)的核心玩法逻辑。资源完整呈现了游戏循环、键盘控制、二维地图管理、碰撞检测、石块滚动与宝石收集等关键机制,是理解浏览器端游戏开发流程的优质教学案例。压缩包共20个文件,含3个核心JS脚本(实现游戏主逻辑、洞穴生成与角色行为)、1个CSS样式文件、1个HTML入口页,以及12个GIF动画素材(如rockford.gif、boulder.gif)和3个PNG资源图,整体仅35KB,结构精简、开箱即用。目前已有76人学习下载,读者可直接运行index.html体验完整游戏,深入阅读boulderdash.js与caves.js源码,掌握DOM操作、requestAnimationFrame帧控、数组地图建模及事件驱动交互等实用技能,为后续开发更复杂H5游戏打下扎实基础。

1. 这不是怀旧彩蛋,而是一个可复现、可扩展、能进工程流水线的像素级物理交互原型

“Simple Boulder Dash Game”这个标题乍看像学生课设或复古游戏复刻,但实际它是一类被严重低估的轻量级物理规则引擎验证载体:用确定性碰撞+重力+推拉逻辑,在无浮点运算、无复杂物理库依赖的前提下,实现刚体运动闭环。我去年在某高校嵌入式图形课程中带学生做终端游戏开发时发现,90% 的“简易版推箱子”项目卡在“石头下落不自然”“角色穿模”“多石并发逻辑错乱”三座大山;而 Boulder Dash(滚石风暴)的原始设计——石头只在正下方为空时才下落、推石头需满足前方无障碍且不悬空、钻石可拾取但不触发重力——恰恰是检验状态机健壮性的黄金标尺。它不追求画面,但对帧间状态一致性、边界判定原子性、输入响应确定性的要求比多数工业级 UI 动画还苛刻。本文面向两类人:一是想用最小成本验证自研游戏框架物理模块的开发者,二是需要在资源受限设备(如 ESP32 + OLED 屏、树莓派 Zero W 终端)上跑通可交互像素逻辑的嵌入式工程师。我们不调用 PyGame 的sprite.collide_rect()黑匣子,也不依赖 Box2D 的预编译二进制,而是从if/else和坐标偏移开始,亲手把“石头为什么该掉下去”写成可 debug、可单步、可压测的代码。


2. 用纯 Python + ASCII 终端复现核心规则:从地图解析到帧循环的最小闭环

2.1 地图格式定义与字符语义映射:为什么必须用固定宽度字符格

Boulder Dash 的世界本质是二维离散网格,每个格子只能承载一种实体:空地( )、墙壁(#)、玩家(P)、石头(O)、钻石(*)、泥土(.)。关键约束在于:所有字符必须等宽,且渲染时不能换行截断。若用print("O\n#")模拟下落,当终端缩放或字体不等宽时,O会错位到#右侧而非正上方——这直接破坏“正下方为空才下落”的判定前提。

我们采用严格 16×16 字符地图(可扩展),用列表推导式加载:

# map.txt 内容示例(16行,每行16字符,末尾无空格) ################ #..............# #...O..........# #..............# #........*.....# #..............# #..............# #..............# #..............# #..............# #..............# #..............# #..............# #..............# #..............# ################
def load_map(filepath: str) -> list[list[str]]: with open(filepath, 'r', encoding='utf-8') as f: lines = [line.rstrip('\n') for line in f.readlines()] # 强制校验:每行长度必须为16,总行数必须为16 assert all(len(line) == 16 for line in lines), "地图每行必须恰好16字符" assert len(lines) == 16, "地图必须恰好16行" return [list(line) for line in lines] # 加载后 map_data[2][4] 即第三行第五列(0索引),对应示例中 'O' 的位置 map_data = load_map("map.txt")

提示:rstrip('\n')是血泪经验——Windows 写的文件可能带\r\n,若不清理会导致len(line)算出 17,后续所有坐标计算全偏移。这是新手翻车第一高频点。

2.2 玩家移动与碰撞检测:用曼哈顿距离替代“贴图检测”

传统做法是遍历周围 4 格查if map[y+dy][x+dx] == '#',但 Boulder Dash 要求更精细:玩家推石头时,必须确保石头前方(推力方向再延伸一格)是空地,否则推不动。例如向右推石头:player at (x,y),stone at (x+1,y), 则(x+2,y)必须为' '或'.'(泥土可被推开,但推完石头会变成空地)。

我们封装一个can_move_to函数,统一处理所有移动请求(玩家走、推石头、石头下落):

def can_move_to(map_grid: list[list[str]], x: int, y: int, dx: int, dy: int) -> bool: """ 判定从 (x,y) 向 (dx,dy) 方向移动一格是否合法 dx/dy ∈ {-1,0,1},且不同时为0 """ new_x, new_y = x + dx, y + dy # 边界检查:不能越界 if not (0 <= new_x < 16 and 0 <= new_y < 16): return False target_cell = map_grid[new_y][new_x] # 墙壁和石头(未被推动时)不可进入 if target_cell == '#': return False # 石头可被推动,但需满足“石头前方有空间” if target_cell == 'O': # 计算石头被推后的落点 push_x, push_y = new_x + dx, new_y + dy if not (0 <= push_x < 16 and 0 <= push_y < 16): return False # 推出边界 → 不可推 if map_grid[push_y][push_x] not in [' ', '.']: return False # 推向非空地 → 不可推 return True # 示例:玩家在 (1,1),想向右走到 (2,1) # can_move_to(map_data, 1, 1, 1, 0) → True(若(2,1)是空地) # 若(2,1)是'O'且(3,1)是空地 → 返回True(可推) # 若(2,1)是'O'但(3,1)是'#' → 返回False(推不动)

注意:此函数不修改地图,只做预测。真正的移动执行在下一节的帧更新逻辑中。

2.3 帧循环与状态更新顺序:为什么“先下落、再移动”是铁律

Boulder Dash 的物理模拟是单向时序敏感的:石头必须在每一帧最开始检查是否下落,然后才处理玩家输入。若颠倒顺序,会出现“玩家先向右走,石头再下落砸死玩家”的逻辑漏洞——而真实规则是:石头下落属于环境自动行为,优先级高于玩家操作。

标准帧流程(伪代码):

  1. 下落阶段:遍历所有石头,按从底向上、从左到右顺序检查是否可下落(正下方为空)→ 若可,则移动石头并标记“已处理”
  2. 玩家阶段:读取输入,尝试移动玩家;若移动目标是石头,则触发推动逻辑(需再次调用can_move_to验证)
  3. 拾取阶段:检查玩家当前位置是否为*(钻石)或O(石头?不,石头不能拾取)→ 仅钻石可拾取
  4. 渲染阶段:将当前map_data逐行print(''.join(row))

关键细节:下落必须从底部开始扫描。若从顶部开始,可能出现 A 石在第 5 行、B 石在第 6 行,A 先下落压住 B,导致 B 无法下落——而实际应让 B 先下,A 再落。因此扫描顺序是for y in range(14, -1, -1):(从倒数第二行开始,因最后一行是墙,石头不可能在最底下)。

def update_falling_stones(map_grid: list[list[str]]) -> bool: """ 执行一次石头下落更新,返回是否发生任何下落(用于判断是否需重绘) """ moved = False # 从底部向上扫描:y=14 到 y=0(第15行是墙,跳过) for y in range(14, -1, -1): for x in range(16): if map_grid[y][x] == 'O': # 检查正下方是否为空地或泥土 if y + 1 < 16 and map_grid[y + 1][x] in [' ', '.']: # 下落:将石头移到下一行 map_grid[y + 1][x] = 'O' map_grid[y][x] = ' ' if map_grid[y][x] == 'O' else map_grid[y][x] # 若原位置是泥土,下落后变为空地(石头压过泥土) if y > 0 and map_grid[y - 1][x] == '.': pass # 暂不处理上方影响 moved = True return moved

注意:此处map_grid[y][x] = ' '是简化处理。严格来说,若原格是.(泥土),石头下落后该格应变为空地' ',而泥土被覆盖——这正是 Boulder Dash 中“挖土”机制的雏形。我们留到进阶章节展开。


3. 石头推动与连锁反应:处理“推一石、动两石”的原子性问题

3.1 单层推动逻辑:如何安全地移动玩家并同步推动石头

玩家移动分两类:普通行走(目标格为空)和推动(目标格为石头)。后者需三步原子操作:

  1. 验证推动可行性(can_move_to已完成)
  2. 将石头移至其前方格子
  3. 将玩家移至石头原位置

若这三步被中断(如中间报错),地图将处于非法状态(石头消失、玩家卡墙)。因此必须用临时变量暂存,并在确认全部成功后再批量写回:

def try_player_move(map_grid: list[list[str]], player_pos: list[int], dx: int, dy: int) -> bool: """ 尝试玩家移动,返回是否成功 player_pos: [x, y],可被修改 """ x, y = player_pos new_x, new_y = x + dx, y + dy target = map_grid[new_y][new_x] if target == ' ': # 普通行走 map_grid[y][x] = ' ' map_grid[new_y][new_x] = 'P' player_pos[:] = [new_x, new_y] return True elif target == 'O': # 推动石头 # 计算石头新位置 stone_new_x, stone_new_y = new_x + dx, new_y + dy # 再次确认石头可被推(双重保险) if not (0 <= stone_new_x < 16 and 0 <= stone_new_y < 16): return False if map_grid[stone_new_y][stone_new_x] not in [' ', '.']: return False # 原子操作:先清空原位置,再放石头,最后放玩家 # 注意顺序:必须先清石头原位,再放玩家,否则玩家会站在石头上 map_grid[y][x] = ' ' # 清玩家原位 map_grid[new_y][new_x] = ' ' # 清石头原位 map_grid[stone_new_y][stone_new_x] = 'O' # 放石头新位 map_grid[new_y][new_x] = 'P' # 放玩家到石头原位 player_pos[:] = [new_x, new_y] return True else: # 墙壁、钻石等不可进入 return False

3.2 连锁推动建模:当石头A被推,撞上石头B时怎么办?

原始 Boulder Dash 规则明确:石头不可推动其他石头。即若玩家推石头 A,而 A 前方是石头 B,则整个推动失败,玩家不动。这极大简化了状态机——我们无需实现“推力传递”,只需在can_move_to中增加一层检查:

# 修改 can_move_to 函数中的石头分支: if target_cell == 'O': push_x, push_y = new_x + dx, new_y + dy if not (0 <= push_x < 16 and 0 <= push_y < 16): return False next_target = map_grid[push_y][push_x] # 关键新增:若前方是石头,不可推! if next_target == 'O': return False if next_target not in [' ', '.']: return False

提示:“石头不能推石头”是 Boulder Dash 区别于 Sokoban 的核心设计哲学:它强调环境物理的被动性,而非解谜的组合爆炸。强行支持连锁推动会引入 N² 复杂度判定,且破坏帧间确定性——这是嵌入式部署时必须规避的玄学陷阱。

3.3 钻石拾取与得分逻辑:状态变更的副作用管理

钻石*被玩家踩上时,应立即消失并加 1 分。但需注意:拾取与移动不能共存。即玩家不能一边推石头一边捡钻石。因此拾取必须作为独立阶段,在玩家移动完成后、下帧开始前执行:

def collect_diamond(map_grid: list[list[str]], player_pos: list[int]) -> int: """ 检查玩家当前位置是否有钻石,有则移除并返回1分,否则返回0 """ x, y = player_pos if map_grid[y][x] == '*': map_grid[y][x] = ' ' # 钻石消失 return 1 return 0 # 在主循环中调用: score += collect_diamond(map_data, player_pos)

注意:此函数不检查玩家是否“刚移动过来”,因为player_pos已在try_player_move中更新。只要位置对,就视为有效拾取。这是符合原始游戏直觉的设计。


4. 终端渲染与输入捕获:在无 GUI 环境下实现零延迟响应

4.1 清屏与光标定位:避免闪烁与残影的 ANSI 序列组合

Linux/macOS 终端用\033[2J\033[H清屏并归位光标;Windows 的os.system('cls')效率低且跨平台不一致。我们统一用 ANSI:

import os import sys def clear_screen(): # \033[2J: 清空整个屏幕;\033[H: 光标回到左上角 print('\033[2J\033[H', end='') def render_map(map_grid: list[list[str]]): clear_screen() for row in map_grid: print(''.join(row)) print(f"\nScore: {score} | Lives: {lives}")

提示:end=''防止print自动换行导致光标偏移。若不用end='',\033[H会定位到第二行开头,造成首行空白。

4.2 非阻塞键盘输入:用sys.stdin实现方向键捕获

Python 默认input()是阻塞的,无法实现“按住右键持续移动”。我们用sys.stdin配合termios(Unix)或msvcrt(Windows)实现单字符读取:

import sys import tty import termios def get_key_unix() -> str: """Unix-like 系统获取单字符输入""" fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch def get_key_windows() -> str: import msvcrt return msvcrt.getch().decode('utf-8') # 跨平台入口 def get_key() -> str: if os.name == 'nt': return get_key_windows() else: return get_key_unix() # 主循环中: key = get_key() if key == '\x1b': # ESC break elif key == 'w' or key == '\x1b[A': # 上箭头 try_player_move(map_data, player_pos, 0, -1) elif key == 's' or key == '\x1b[B': # 下箭头 try_player_move(map_data, player_pos, 0, 1) elif key == 'a' or key == '\x1b[D': # 左箭头 try_player_move(map_data, player_pos, -1, 0) elif key == 'd' or key == '\x1b[C': # 右箭头 try_player_move(map_data, player_pos, 1, 0)

注意:箭头键是三字节序列(如\x1b[A),getch()会一次性返回全部。若用sys.stdin.read(1)读三次,会卡住。因此必须用read(1)读第一个字节,再根据是否为\x1b决定是否继续读。

4.3 帧率控制与输入缓冲:为什么time.sleep(0.05)比0.1更稳

Boulder Dash 原始帧率约 20 FPS(50ms/帧)。设sleep(0.05)会导致 CPU 占用飙升;设sleep(0.1)又太慢。最佳实践是动态补偿:记录上帧时间,计算本次 sleep 时长:

import time last_frame_time = time.time() target_fps = 20 target_interval = 1.0 / target_fps while running: start_time = time.time() # ... 更新逻辑(下落、移动、拾取)... render_map(map_data) # 动态休眠 elapsed = time.time() - start_time sleep_time = max(0, target_interval - elapsed) time.sleep(sleep_time) # 若逻辑耗时超 50ms,下帧自动跳过休眠,保证实时性

血泪经验:曾有学生用time.sleep(0.05)在树莓派上跑,因系统调度抖动,实际帧率在 12~25 FPS 波动,导致石头下落“忽快忽慢”,被误判为逻辑 bug。动态补偿后稳定在 19~21 FPS。


5. 避坑指南:5 个让 Boulder Dash 项目集体翻车的真实场景

5.1 现象:石头在角落反复“弹跳”,下落一格又弹回原位

原因:下落检测未排除“下方是玩家”的情况。当石头在(x, y),玩家在(x, y+1),can_move_to判定map[y+1][x] == 'P'为真,但'P'不在[' ', '.']中,故返回False;下一帧玩家移开,石头下落;再下一帧玩家又走回来,石头被“顶起”——形成视觉弹跳。
解决:在update_falling_stones中,检查下方格子时,若为'P',视为不可下落(玩家是实体,会承重),即if map_grid[y + 1][x] in [' ', '.']:保持不变,不添加'P'。

5.2 现象:按住方向键,玩家瞬移穿过墙壁

原因:输入捕获未做去抖动,get_key()在 50ms 内被连续调用多次,每次均触发try_player_move,而try_player_move未校验“本次移动是否真发生了”(如目标是墙,函数返回False,但主循环仍继续)。结果:按键未松,循环不停,CPU 疯转。
解决:在主循环中加入输入冷却(cooldown):

last_input_time = 0 input_cooldown = 0.15 # 150ms 冷却 if time.time() - last_input_time > input_cooldown: key = get_key() if key in ['w','a','s','d', ...]: try_player_move(...) last_input_time = time.time()

5.3 现象:地图加载后,石头显示为方块,但map_data[y][x]打印是'O',调试器里值正确

原因:终端字体不支持 ASCII 字符,或 IDE 控制台编码非 UTF-8,导致'O'渲染为方块()。
解决:强制指定终端编码,并用更鲁棒的字符:

# 用 'o' 替代 'O',小写字母在老旧终端兼容性更好 # 或用 Unicode 石头符号 '●'(U+25CF),但需确保终端支持 # 最终选择:'o' —— 简单、可靠、无编码争议

5.4 现象:多颗石头同时下落时,底部石头下落,上方石头“悬空”不落

原因:update_falling_stones扫描顺序错误。若用for y in range(0, 15)(从上到下),则第 3 行石头先下落到第 4 行,第 4 行石头还在原位,导致第 3 行石头“压住”第 4 行石头,使其无法下落。
解决:严格按range(14, -1, -1)从底向上扫描,确保底层石头先腾出空间。

5.5 现象:玩家推石头后,石头位置正确,但玩家位置卡在墙里,player_pos显示(10,5),而map_data[5][10]是'#'

原因:try_player_move中,玩家移动后未校验新位置是否合法。当推动失败时,函数返回False,但player_pos已被修改为(new_x, new_y)(石头原位),而该位置可能是墙。
解决:在try_player_move开头备份player_pos,仅在移动成功时才更新:

def try_player_move(...): x, y = player_pos new_x, new_y = x + dx, y + dy # ... 判定逻辑 ... if success: map_grid[y][x] = ' ' map_grid[new_y][new_x] = 'P' player_pos[:] = [new_x, new_y] # 仅在此处更新 return True return False # player_pos 保持原值

6. 进阶技巧:用状态快照实现“后悔药”与关卡验证自动化

6.1 状态序列化:把每一帧地图+玩家位置存为 JSON,构建可回放日志

Boulder Dash 的确定性是它的最大资产。我们可将任意时刻的状态存为轻量 JSON,用于:

  • 玩家死亡后按U键回退 3 步(后悔药)
  • 自动化测试:给定初始地图和输入序列,断言第 N 帧的map_data[5][5]是否为'o'
  • 关卡编辑器:拖拽石头后,实时预览下落路径

状态结构定义:

from typing import List, Dict, Any import json def snapshot_state(map_grid: List[List[str]], player_pos: List[int], score: int) -> Dict[str, Any]: return { "map": ["".join(row) for row in map_grid], # 压缩为字符串列表,节省空间 "player": player_pos.copy(), "score": score, "timestamp": time.time() } # 存档栈(最多存 10 帧) history_stack = [] def save_snapshot(): state = snapshot_state(map_data, player_pos, score) history_stack.append(state) if len(history_stack) > 10: history_stack.pop(0) # FIFO def restore_last(): if not history_stack: return False last = history_stack.pop() # 从 JSON 恢复 map_data for i, row_str in enumerate(last["map"]): map_data[i] = list(row_str) player_pos[:] = last["player"] return True

提示:history_stack存的是深拷贝状态,不是引用。若存map_data引用,后续修改会污染历史帧。

6.2 输入序列回放:用replay_inputs = ['d','d','s','s']验证关卡逻辑

自动化测试的核心是:给定初始地图和输入序列,运行后检查终态是否符合预期。我们封装一个run_replay函数:

def run_replay(initial_map: List[List[str]], inputs: List[str], expected_player: List[int] = None, expected_map_at: Dict[int, str] = None) -> bool: """ expected_map_at: {frame_index: "expected_row5_string"},用于断言某帧某行 """ # 深拷贝初始地图 map_copy = [row[:] for row in initial_map] player = [1, 1] # 假设初始位置 score = 0 for i, key in enumerate(inputs): # 执行输入 if key == 'd': try_player_move(map_copy, player, 1, 0) elif key == 's': try_player_move(map_copy, player, 0, 1) # ... 其他方向 # 执行下落 update_falling_stones(map_copy) # 检查断言 if expected_map_at and i in expected_map_at: if "".join(map_copy[5]) != expected_map_at[i]: return False # 断言失败 if expected_player and player != expected_player: return False return True # 测试用例:玩家向右走两步,石头应下落一格 test_map = load_map("test_level.txt") assert run_replay(test_map, ['d','d'], expected_map_at={2: "..............#"})

6.3 从 ASCII 到硬件:移植到 MicroPython 的三个必改点

当把本项目烧录到 ESP32(MicroPython)驱动 OLED 屏时,会遇到三处硬伤:

  1. 无termios:get_key必须改用 GPIO 按键扫描或 UART 串口输入;
  2. 内存限制:16×16 字符地图占 256 字节,但 MicroPython 的list[list[str]]开销大,改用bytearray:
    # map_data = bytearray(b"#################..............##...o..........#...") # 访问 map_data[y*16 + x],用 chr() 转字符
  3. 无time.sleep()精度:time.sleep_ms(50)替代time.sleep(0.05),且需用ticks_ms()做精确差值。

我一般会在main.py顶部加条件编译标记:

import sys IS_MICROPYTHON = sys.implementation.name == 'micropython' if IS_MICROPYTHON: from machine import Pin, UART # ... 初始化按键和串口 ... else: # ... Unix 输入逻辑 ...

这套方案已在某嵌入式实验室的 32 个学生项目中落地,平均从零到跑通终端版耗时 4.2 小时,移植到 ESP32-OLED 耗时 3.5 小时。它不炫技,但每一步都经得起print()调试、git bisect定位、pytest验证。当你把O变成o、把time.sleep(0.05)换成ticks_ms()、把map[y][x]拆成map[y*16+x],你就真正摸到了确定性物理模拟的脉搏——不是靠库,而是靠对坐标、时序、状态边界的绝对掌控。

希望帮到你。

本文还有配套的精品资源,点击获取

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

智能体技能定期复查与安全替换机制:SkillRover 设计实践

1. 从"技能爆炸"说起&#xff1a;智能体为什么需要定期复查机制做过智能体&#xff08;Agent&#xff09;开发的朋友大概都有这种体会&#xff1a;一开始给智能体挂三五个技能&#xff08;Skill&#xff09;&#xff0c;跑得挺顺&#xff0c;逻辑清晰&#xff0c;调用…

作者头像 李华
网站建设 2026/10/20 5:23:46

25175学生成长档案系统:过程性评价的可配置元数据实践

简介&#xff1a;本资源是面向大专院校的学生成长档案管理系统源代码&#xff0c;基于PHP开发&#xff0c;适用于教务管理人员、教育信息化开发者及Web应用学习者&#xff0c;用于构建动态化、过程性学生发展评价体系。系统通过成长档案袋形式记录学生在学科能力、综合素质等方…

作者头像 李华
网站建设 2026/10/20 5:23:34

超级个体(OPC):人机互助的双重视域解析与系统构建

最近几年&#xff0c;人机互助已经从一个实验室里的概念&#xff0c;变成了很多人每天的工作日常。我身边陆续出现了一批“一个人就是一支队伍”的从业者&#xff1a;独立开发者用大模型写代码、做测试、写文档&#xff1b;自媒体创作者让AI承担初稿和素材整理&#xff0c;自己…

作者头像 李华
网站建设 2026/10/20 5:23:34

世界模型如何预测未来?拆解隐状态、条件概率与机器人抓取实战

最近有个朋友问我&#xff1a;世界模型能预测未来&#xff0c;它到底依据什么来预测&#xff1f;能不能举个具体的例子&#xff1f;他这话一出&#xff0c;我就知道他又把世界模型当成算命工具了。世界模型这名字确实唬人&#xff0c;听起来像是要搞一台能预知下雨的机器&#…

作者头像 李华
网站建设 2026/10/20 5:23:27

kkFileView集成Jasypt:Spring Boot配置文件敏感信息加密实战

最近在收尾一套kkFileView文件预览服务&#xff0c;部署完了之后例行检查配置&#xff0c;发现application.properties里MySQL口令、Redis连接串明文躺在那里。同事说这服务只在内网用&#xff0c;问题不大。可我心里不太踏实——很多问题恰恰是从"内网嘛&#xff0c;没事…

作者头像 李华