文件:tank_game.py(游戏主体)、tank_coach.py(表现评估模块)、tank_career.py(生涯战绩)、tank_audio.py(程序化音效:开火 / 命中 / 爆炸 / 复活 / 胜负 / 菜单,无外部素材)、tank_version.py(版本号唯一来源)、tank_paths.py(可写数据目录:开发态脚本旁 / 打包态 %LOCALAPPDATA%\TankGame)、tank_keys.json(开火键配置,可删,会自动重建)、tank_coach.json(对战评估历史,可删,会自动重建)、tank_career.json(生涯胜负与最近对局,可删,会自动重建)、tank_preview.png(预览截图)、fonts/(可选打包用 CJK 字体)、packag
8000
ing/windows/(PyInstaller / MSIX / 商店上架)、_selftest*.py / _preview.py(自测与截图脚本,其中 _selftest_lives.py 专测生命值与复活、_selftest_coach.py 专测评估、_selftest_career.py 专测生涯战绩、_selftest_paths.py 专测数据目录、_selftest_turn.py 专测转向粒度与瞄准精度、_selftest_walls.py 专测障碍物、_selftest_body.py 专测两车车体互斥、_selftest_timing.py 专测固定逻辑步累加器、_selftest_audio.py 专测音效合成与静音降级)、_sim_coach.py(评估的交叉验证沙盘)。
下文所有命令都在本目录(tank/)下执行,例如 cd tank && python3.11 tank_game.py。
- 开火、子弹命中车体 / 墙壁、坦克被击毁、复活、开打、胜负、菜单移动 / 确认都有音效;双人同屏时按弹着点在屏幕上的位置做轻微左右声像。
- F6 随时静音 / 恢复(屏幕中央有提示)。启动日志会打印
[audio] 音效已启用:11 种…;没有声卡 / 音频驱动不可用时打印静音提示,游戏照常运行。 - 音色由
tank_audio.py用正弦波、方波和噪声现场合成,不读取任何音频文件,打包不需要额外素材;想改音色直接改该模块里的_make_*函数。 - 环境变量:
TANK_SOUND=0启动即关闭音效;TANK_SOUND_VOLUME=0.4调主音量(0–1,默认 0.75)。
启动默认进入主菜单:单人 / 双人 / AI 难度 / 地图 / 战绩 / 改开火键 / 退出。选模式后进入等待开始,再确认开打。局末「回主菜单」返回此处(不关游戏)。脚本自测可用 TANK_SKIP_MENU=1 或 --1p/--2p 跳过主菜单。
每局结束会按 P1(红/你) 视角记一笔生涯战绩:1P 与 2P 分开累计胜/负/击毁/被毁,并保留最近 20 局。主菜单选「战绩」打开只读面板(汇总 + 最近对局);键盘 Esc/Enter/Y 或手柄 X / Back 返回。不按 coach 分数排行;重置可删 tank_career.json。
- 每台坦克 5 条命。血条(
MAX_HP = 100)打空算「被击毁一次」,扣 1 条命,不再直接分胜负。每发DAMAGE = 10血,也就是一条命要中 10 发才掉,约 7 秒(FIRE_COOLDOWN = 18帧 => 双方 3.3 发/秒封顶)。 - 被击毁后原地消失,1 秒后(60 帧)满血复活:位置、朝向、冷却清回开局状态。
- 复活点是四个角里离对手最远的那个(
safe_respawn_point()),朝向自动指向对手——固定回开局角等于送死:边线挡着退不了,无敌一到点就在对手枪口前。 - 复活后有 2 秒(120 帧)无敌:车体外围会显示一圈白环,这期间挨打不掉血(子弹会被弹掉),避免刚出生就被补枪。2 秒够打 7 发、走约 380px。
- 死亡等待期间,实际复活点上会显示
RESPAWN 1.0s倒计时;命数扣到 0 才判负、出现胜负提示。 - 命数显示在屏幕左上/右上:
HP xxx下面一排 5 个小方块,亮的 = 还有的命,暗的 = 已经丢掉的。 - 三个数字都写死在
tank_game.py里,不开放环境变量:MAX_LIVES = 5、RESPAWN_DELAY = 60(帧)、SPAWN_INVULN = 120(帧)。 - 命中判定是圆:车体中心到子弹中心 ≤
HIT_RADIUS(18) + BULLET_RADIUS(4)= 22px 才算打中(唯一出处Bullet.touches())。不用旋转贴图的包围盒——44px 贴图转到 45° 时包围盒会涨到 62×62,视觉上明明擦着车边飞过去也会掉血。 - 被击毁、复活、命尽都会在终端打一行
[life] ...日志,方便对表确认流程。
内置 9 张地图(MAPS),默认 PILLARS;在切 1P/2P 后的等待开始界面按 M 或手柄 RB+Y 循环切换,开打后本局锁定。掩体挡车、挡子弹、挡视线(AI 看不见你就不开枪)。
| 地图 | 说明 |
|---|---|
| EMPTY | 无掩体 |
| PILLARS | 4 个 56×56 柱(原布局):(240,120)/(240,300)/(504,244)/(504,424) |
| CORRIDORS | 中线上下两条横廊:(280,180,240×40) 与对称块 |
| CROSS | 中心十字四臂,四象限经外圈走廊互通 |
| DIAGONAL | 四条横柱沿出生对角线斜置,中央全空 |
| CORNERS | 四角 80×80 大方块,左右 20px 竖缝只过子弹不过车 |
| RING | 四块围环,对角 64px 缺口,内圈四面可入 |
| HALLS | 纵向双廊(CORRIDORS 竖版),三条走廊 |
| BLOCKS | 中央 2×2 方块阵,两条出生对角线从阵心穿过 |
- 每张非空图都满足三条硬约束(
_selftest_walls.py逐张断言):180° 旋转对称、不压出生点/复活角(离四角 ≥ 62px)、不挡两条出生对角线(两条对角线斜率 ±9/14,不是 45°);方块厚度 ≥ 子弹两帧位移(18px)。 - 不随机、不破坏:R / START 重开沿用本局地图;
TANK_WALLS=0时所有地图都建成空组。 - 碰撞/视线判定只有一份出处(都在
tank_game.py):obstacle_at()、resolve_obstacles()、has_line_of_sight()、tank_blocked_ahead()、separate_tanks()、clamp_to_field()。 - AI 认得掩体:隔墙不开枪;撞墙停车并朝一侧绕行(不做寻路)。每张非空图都有 1500 帧 AI vs AI 回归,确认掩体没把 AI 卡死。
- 子弹先撞墙,再看坦克:穿墙命中不成立;隔墙弹在评估里按 miss 计。
python3.11 tank_game.py # 默认 PILLARS
TANK_WALLS=0 python3.11 tank_game.py # 关掉全部掩体两台坦克的车体在位置上真的互相作用:最小中心距锁在 2 * TANK_HALF = 44px,站不进对方车里(以前两车可以完全重合,「穿模」时还能贴着对方车心打)。
- 对称分离,双方各让一半:重叠时沿两车中心连线各推开穿透量的一半(
separate_tanks()),不是「谁后动谁推人」。1p / 2p、人类 / AI 一样公平。 - 被墙 / 边界吃掉的部分由另一台承担:每次推完各自跑
resolve_obstacles()+clamp_to_field()压回掩体和场地;一台被墙挡住、推不动,差额就补给另一台(掩体与边界 = 无限质量)。所以「贴墙被顶」不会两台一起陷进方块。 - 1px 容差 + 幂等:分到「44px 减 1px」就收手(与
tank_blocked_ahead的TANK_HALF - 1同一口径),已经分开的帧坐标一位不动,贴着不会抖。完全重合(中心距 0)时沿固定轴分开,不会出现除零 / NaN。 - 帧末执行一次:主循环在两台车都
drive()完、子弹命中判定之前调一次(一局结束后不再推挤)。它不在drive()里,因为drive()是单车逻辑、两台车先后各调一次,写进去就变成「后动的那台单方面推前一台」。 - AI 认得对手车体:
tank_blocked_ahead(..., other=target)把「探针进到两车相切 - 1px 以内」也判成挡;被顶到 46px 以内时 AI 这一帧倒车让开(瞄准和开火照常),顶在墙角时「边转身边倒车」沿墙滑出来,而不是原地顶牛。 - 唯一出处:
separate_tanks()(车 × 车)与clamp_to_field()(屏幕边界,从Tank.drive()里抽出来的)都在tank_game.py。 - 自测:
SDL_VIDEODRIVER=dummy python3.11 _selftest_body.py覆盖 6 条 —— ① 面对面全速 120 帧不重叠;② 20px 穿透 -> 双方各退 10px(对称);③ 已分开时连调 5 次坐标一位不动(幂等);④ 一车被顶在方块上时差额由另一台承担、没人陷进方块;⑤ 完全重合也能分开且无 NaN;⑥ 玩家车全速死顶 AI 300 帧,AI 保持 ≥43px、一直在动、照常开火。车 × 车互斥只由这个脚本覆盖(_selftest_ai.py的sim()是「单车 AI + 静止靶」的单向场景,故意不接分离)。
游戏里有两条手柄通道,启动时自动选择:
- SDL 标准通道(首选):手柄在 SDL 内置映射库里查得到,就按语义读取(左摇杆、ABXY、模拟扳机、Start/Back),不用记轴号按钮号,插上即可用。
- 原始 joystick 兜底:SDL 不认这台设备(映射库里没有它的条目)时,按 XInput 常见布局猜——左摇杆 = 轴 0/1,开火 = 按钮 0–3,十字键读 hat。猜错了用下文的
TANK_PAD_AXES等开关改。
先确认自己在哪一档:
python3.11 tank_game.py --pad-scan
# 无显示器 / SSH / CI 环境加 dummy 驱动,只做诊断
SDL_VIDEODRIVER=dummy python3.11 tank_game.py --pad-scan每台设备后面会跟一行判定:
| 扫描输出 | 含义 |
|---|---|
-> 标准映射(SDL 认,直接可用) |
走第 1 条通道,不用再调 |
-> 无 SDL 映射 → 走原始 joystick 兜底 |
走第 2 条通道,摇杆/开火不对就按调参表改 |
-> 无 SDL 映射,且兜底已被 TANK_PAD_RAW=0 关闭 |
兜底被环境变量关掉了,这台设备用不了 |
判定依据(实测这台机器上 pygame 2.6.1 自带的 SDL 2.28.4 映射库):Xbox 条目 16 条(USB 10 / 蓝牙 6),8BitDo 条目 40 条(含 AB 互换的重复项)全部以 0300(USB)开头,0500(蓝牙)0 条。查不到映射表的设备一般落到兜底通道;但 macOS 上经 Apple Game Controller / MFI 暴露的蓝牙手柄(GUID 以 0500 开头)仍可能被 SDL 标成 is_controller=True,走标准通道——不要只看映射库条目数。
| 手柄 | Windows 2.4G / 有线 | Windows 蓝牙 | macOS 有线 | macOS 蓝牙 |
|---|---|---|---|---|
| Xbox 官方(Series X·S / One S / Elite) | ✅ 走 XInput,不查映射表 | ✅ | ✅ | ✅ |
| 8BitDo Ultimate 3mode Xbox | ✅ 官方主推 2.4G/有线 | 🟡 | 🟡 有线当 Xbox 时 ✅ | ✅ 经 Apple GC/MFI 标准映射(实测 GUID 0500…,hats=1) |
| 8BitDo 猎户座 XPro | ✅ Xbox 授权,即插即用 | 🟡 官方规格里 PC 走 2.4G/有线,蓝牙主要面向 Apple/Android | 🟡 看固件上报的 VID 是否被当成 Xbox,--pad-scan 一验便知 |
🟡 |
| EasySMX X05 | ✅ 切到 XInput 模式后 | — 无蓝牙 | 🟡 | — |
- ✅ 直接可用;🟡 能玩,但要走兜底通道,摇杆轴或扳机可能要按调参表指定;— 该连接方式不存在。
- macOS 蓝牙要求 macOS 11+(Apple Silicon 建议 12+);
--pad-scan里显示「标准映射」就说明系统层和 SDL 都认了。 --pad-scan/ 启动绑手柄前会短暂泵事件:macOS 蓝牙手柄在set_mode当下get_count()常仍为 0,必须处理一轮JOYDEVICEADDED/CONTROLLERDEVICEADDED才现身;用SDL_VIDEODRIVER=dummy做 CI 诊断时本来就没有真设备,结果仍是 0。- Ultimate 3mode Xbox 官方更稳的 PC 路径仍是 2.4G / USB;macOS 蓝牙已实测可玩,但若
--pad-scan仍为 0,先确认不是 dummy 驱动,再试接收器/数据线。 - 蓝牙手感卡 / 掉帧:命中震动走 BLE 时常拖主线程。默认对 GUID 前缀
0500(蓝牙)关闭震动;仍卡优先改用 2.4G 接收器或 USB。要强制开震动:TANK_PAD_RUMBLE=1;全关:TANK_PAD_RUMBLE=0。 - 想确认手柄是不是原厂:Xbox 官方手柄的 GUID 形如
030000005e04…(USB)/050000005e04…(蓝牙),5e04就是微软 VID045e的字节序。仿品/翻新常被改成别的 VID,正好落进兜底通道——能玩,但要调参。
两只不同型号能同时用吗:能,两只手柄各走各的通道、各自算上升沿,互不干扰(同型号更是没问题)。要注意三点:
- 槽位顺序不是纯插入顺序:先排所有 SDL 认得的(标准通道),再排兜底通道的。所以「一只标准 + 一只杂牌」时,标准那只固定是 P1(红),杂牌那只固定是 P2(蓝),跟谁先插无关。
- 调参开关是全局的:
TANK_PAD_AXES/TANK_PAD_FIRE/TANK_PAD_RAW_TRIGGERS对两只手柄一起生效,只有扳机轴按通道分开(标准(4,5)、兜底空)。所以混插时如果为了救兜底那只去改TANK_PAD_AXES,标准那只的左摇杆也会被带偏(实测:TANK_PAD_AXES="2,3"下标准手柄从turn=+1.00变成+0.00)。要混插,优先挑两只都进 SDL 映射库的型号。 - 震动只有标准通道有:兜底那只不震,标准那只正常震。
买之前先跑一遍 --pad-scan(型号能查到有人用过、输出「标准映射」最稳);已经在手的可以用 TANK_PAD_SCAN=1 直接验。
不需要,功能多在这个游戏里基本用不上:
- 对战本身:左摇杆 X/Y(外加十字键 hat)、开火按钮。菜单:BACK/GUIDE 或 LB+RB 切 1P/2P;摇杆键或 LB+X 切难度;胜负后进入中央操作菜单(再来一局 / 战报 / 切模式 / 切难度 / 退出),战报需从菜单打开。右摇杆方向、体感、连发/宏用不上;背键也只在你按 F4 主动绑定时才有意义。
- 开火是上升沿(按住只打一枪,冷却 18 帧),所以 turbo、微动扳机、自适应扳机这些卖点在这里没有任何体现;摇杆只有「满推 / 半推」两种手感,精度要求很低。
- 震动只是装饰:只在被击毁时震 180ms,没有也不影响游戏。
- 真正值得在意的是两条:系统/SDL 认不认得(决定走标准通道还是兜底通道)、2.4G 接收器别在系统里报成「键盘+鼠标」组合设备(那样游戏根本看不见它)。
结论:能切 XInput 的百元内手柄就够,两只同型号最省心;霍尔摇杆、1000Hz 回报率、RGB 这类溢价点在这个游戏里不用考虑。
| 开关 | 作用 | 示例 |
|---|---|---|
--pad-scan / TANK_PAD_SCAN=1 |
只打印识别结果 + 排错建议,不进游戏 | python3.11 tank_game.py --pad-scan |
TANK_PAD_DEBUG=1 |
打印手柄打开信息、每个按钮事件、坦克反查结果 | TANK_PAD_DEBUG=1 python3.11 tank_game.py |
TANK_PAD_AXES="x,y" |
换左摇杆轴号,标准通道和兜底通道都生效 | TANK_PAD_AXES="2,3" |
TANK_PAD_FIRE="..." |
开火按钮号列表;标准通道下是语义号(0=A 1=B 2=X 3=Y 4=BACK 6=START 9=LB 10=RB 11–14=十字键 …) | TANK_PAD_FIRE="9,10" 只用肩键 |
TANK_PAD_RAW_TRIGGERS="l,r" |
仅兜底通道:启用扳机轴开火 | TANK_PAD_RAW_TRIGGERS="4,5" |
TANK_PAD_RAW=0 |
整体关闭兜底通道,只认 SDL 标准手柄 | TANK_PAD_RAW=0 python3.11 tank_game.py |
TANK_PAD_RUMBLE |
手柄震动:auto(默认,蓝牙 GUID0500关、USB开)/ 0全关 / 1全开 |
TANK_PAD_RUMBLE=0 |
TANK_KEYS_FILE=... |
开火键配置文件位置(默认见下「数据目录」) | TANK_KEYS_FILE=/tmp/k.json |
TANK_DATA_DIR=... |
覆盖整个可写数据根(keys/coach/career) | TANK_DATA_DIR=/tmp/tankdata |
TANK_MODE / TANK_AI |
默认模式与 AI 难度;配合 --1p/--2p 会跳过主菜单 |
TANK_MODE=1p TANK_AI=easy |
TANK_SKIP_MENU=1 |
跳过主菜单,直接按当前模式开打(自测用) | TANK_SKIP_MENU=1 python3.11 tank_game.py |
TANK_COACH=0 |
关闭 1p 模式下的表现评估(不统计、不写历史) | TANK_COACH=0 python3.11 tank_game.py --1p |
TANK_COACH_FILE=... |
评估历史文件位置(默认见「数据目录」) | TANK_COACH_FILE=/tmp/c.json |
TANK_CAREER_FILE=... |
生涯战绩文件位置(默认见「数据目录」) | TANK_CAREER_FILE=/tmp/career.json |
TANK_WALLS=0 |
关掉场地里的全部掩体方块(排查「是不是墙导致的」问题时用) | TANK_WALLS=0 python3.11 tank_game.py |
TANK_SOUND=0 |
关闭全部音效(游戏中也可按 F6 静音) | TANK_SOUND=0 python3.11 tank_game.py |
TANK_SOUND_VOLUME |
主音量 0–1(默认 0.75) | TANK_SOUND_VOLUME=0.4 python3.11 tank_game.py |
数据目录:开发态默认写在脚本同目录;PyInstaller / MSIX 打包后写 %LOCALAPPDATA%\TankGame\(安装目录只读)。单文件环境变量仍可覆盖。自测:python3.11 _selftest_paths.py。
两条固定参数(写在 tank_game.py 里,不开放环境变量):摇杆死区 PAD_DEADZONE = 0.25(死区外线性过渡,不是非黑即白),扳机过 PAD_TRIGGER_ON = 0.5(行程一半)即算开火,脉冲扳机的两段手感不影响这个判定。
兜底通道下扳机默认关,原因是轴号只能靠猜,猜错就会变成「一直开火」;确认轴号后显式打开即可。
| 动作 | 手柄 | P1 键盘 | P2 键盘 |
|---|---|---|---|
| 转向 | 左摇杆左右(十字键被 SDL 报成 hat 的型号也可用) | A / D | ← / → |
| 前进、后退 | 左摇杆上下(同上) | W / S | ↑ / ↓ |
| 开火 | A/B/Y、LB/RB、RT/LT(默认不含 X) | SPACE(可改) | ENTER(可改) |
| 暂停(对局中) | X 打开/关闭暂停菜单 | P 或 Esc | 同左 |
| 暂停菜单 | 十字键/摇杆 + Start(+)/Y 确认;再按 X 继续 | ↑↓ + Enter;数字 1–3;P/Esc 继续 | 同左 |
| 子菜单退出(战绩 / 战报 / F4) | X 或 Back(-) | Esc(战绩另可用 Enter/Y;F4 另可用 F4) | 同左 |
| 主菜单 | 十字键/摇杆上下 + Start(+) 或 Y 确认 | ↑↓/WS + Enter/空格;数字 1–7 | 同左 |
| 局末菜单(分出胜负后) | 十字键/摇杆上下 + Start(+) 或 Y 确认(A/开火不确认);战报层 X/Back 返回 | ↑↓/WS + Enter/空格;数字 1–5 | 同左 |
| 切 1P ↔ 2P | BACK(- 键)/ GUIDE,或 LB+RB 同时按 | F2 | 同左 |
| 确认开打(切模式后) | START(+),或 R / 任意开火键 | R / 开火 | 同左 |
| 切地图(仅等待开始) | RB 按住再按 Y | M | 同左 |
| 循环 AI 难度 | 左/右摇杆按下,或 LB 按住再按 X | F3 | 同左 |
| 改键 / 切评估面板 / 静音 | —(仍用键盘) | F4 / F5 / F6 | 同左 |
8BitDo Ultimate 3mode Xbox:Start = 机身顶栏靠右的 +(Plus/Menu),Back = 顶
8BD0
栏靠左的 -(Minus/View)。macOS 蓝牙若 + 无响应,局末菜单改按人脸键 Y 确认。切 1P/2P 后不会立刻开打;等待界面可切地图,再按 Start(+)/R/开火开打。
-
开火是上升沿:按住只打一枪,不会连发;冷却 18 帧(约 0.3 秒)。
-
转向是两段式:起手一帧只转 1°,同向按住约 0.13 秒后升到满舵 4°/帧(240°/s)。所以「点一下转一格、按住甩炮口」,详见下文「转向:4°/帧 与点按微转」。
-
屏幕左上/右上的
HP 100 ANG 042 ERR +1.5 DIST 412是血量 + 炮口角度 + 到对手的瞄准残差(度)+ 到对手的实时距离(px):点按时盯着 ERR 收进 ±2° 再开火,就不会再出现「怎么都对不上」的感觉。DIST是两车中心的欧氏距离,超过 560px(AI_FIRE_RANGE)打的子弹会先飞出屏幕、等于白送一次冷却——先靠近再开火。注意 F5 面板 / 局末报告里的DIST是开火那一刻的平均距离,跟这里随走位实时变化的数字不是一回事。 -
胜负:一方 5 条命全丢才算输;中途被打掉命只是复活,不会结束这局。局末中央显示双方战损,并出现操作菜单:再来一局 / 战报(仅 1P 有评估时)/ 切 1P↔2P / 切 AI 难度 / 回主菜单;战报不自动弹出。手柄菜单用 Start(+) 或 Y 确认(A 不确认);键盘可用 Enter/空格/数字键。局末「回主菜单」不关游戏;主菜单「退出」才结束。切模式后需再确认开打;macOS 蓝牙若
+/-无响应,菜单用 Y,其它用 LB+RB / LB+X 和弦即可。 -
对局中 P / Esc / 手柄 X 打开暂停菜单(继续 / 回主菜单 / 退出游戏);对战时 Esc 不再直接关窗口。主菜单、等待开打、局末、F4 配置里 Esc 保持原语义。再按 P/Esc/X 或选「继续」恢复;LB+X 仍只切难度、不进暂停。窗口失焦(Alt-Tab / 最小化)会自动暂停,避免切出去后 AI 继续开枪。
-
子菜单(战绩、局末战报、F4 改键、暂停)退出统一用手柄 X(兼 Back);列表菜单确认仍是 Start(+)/Y。
-
改键:按 F4 →
1/2改键盘 P1/P2(按一下新键),3改手柄(按一下手柄按钮;绑定时按 X 取消),0恢复默认(SPACE / ENTER / A·B·Y+肩键,不含 X)。改完立刻原子写入tank_keys.json,下次启动自动读回;配置读坏或键名非法会退回默认并打印原因,不会开不了游戏。ESC、F4 或手柄 X 关闭面板,面板打开时对战暂停。 -
ESC、P、F1–F6、M 是保留键,不能被绑成开火键。
-
手柄的背部拨片(8BitDo 猎户座 XPro、Xbox Elite)在标准通道下是语义按钮 16–20,HUD 里显示
PADDLE1等,可直接在 F4 面板里绑定;猎户座 XPro 的拨片也可以先用 8BitDo 官方软件映射成任意键再用。
- 满舵转速:
Tank.turn_speed = 4.0(度/标准帧 @60fps)=TURN_SPEED_DEG_PER_SEC = 240°/s,转 90° 约 0.375 秒。手柄摇杆推到底、电脑坦克打满舵,都是这个速度。 - 为什么以前总是对不上敌人:旧版键盘只有 ±1 两档,所以每一步都走 4°,可达角度是「起点 + 4k°」的格点;而敌人方向是
atan2算出来的连续实数,跟最近格点必然差最多 2.0°、平均 1°,数学上永远等于不了。更糟的是人手一次轻敲通常持续 36 帧(1224°),过冲之后只能反向过冲,炮口就在目标两侧来回摆。 - 差 1~4° 到底有多大:子弹从炮口直线飞,角度误差 e 在距离 d 上横向偏
d·tan(e),命中半径只有 22px。所以 400px 处:1° 偏 7px(中)、2° 偏 14px(中)、3° 偏 21px(擦边中)、4° 偏 28px(打空)。也就是说「多按一帧」就白打一枪——不是感觉问题,是几何问题。 - 现在的做法(
read_keyboard_axes()):键盘起手一帧只给TURN_FINE_GAIN = 0.25的增益(= 1°/帧),同向按住TURN_RAMP_FRAMES = 8帧(约 0.13 秒)后线性升到满舵。点一下走 1°、可以一格一格把炮口停在任意角度上;按住不放仍然 240°/s,甩炮口不吃亏。手柄摇杆本来就是连续量,不走这条加速曲线。 - 和帧率无关:主循环用固定 60Hz 逻辑步(
fixed_steps()累加器),正常帧跑 1 步、掉帧时本帧补跑多步(单帧最多补 0.1 秒);turn_speed/speed都是「每标准帧」的量,drive(turn, move, dt)收到的dt恒为 1/60(自测脚本不传时同样),转速/车速与子弹、冷却、复活共用同一个时间基准。 - 数值都有自测兜着:
_selftest_turn.py验证「4° 格点残差 2.0° → 点按后残差 ≤0.5°」「过冲一帧在 400px 上打空」「30fps 与 60fps 转过的角度差 <10%」「满舵仍是 4°/帧」。
- 复活是原坦克复位,不重建对象:命还在时被击毁 → 1 秒后回到复活点(默认开局角,
main()会换成离对手最远的角),血量、冷却、闪白、无敌全部按复活规则重置;屏幕上飞着的子弹不会跟着清掉。 - AI 没有寻路:遇到掩体只是「停车 + 固定朝一侧绕」,打得出去、前面不挡了就恢复瞄准。所以它仍可能被顶在死角来回磨(真绕不出来会换一边,但会浪费时间),也不会主动找掩体躲——躲柱子它会先过来找你。两车互斥之后它多了一条自保:被顶到 46px 以内会倒车让开(见上文「两车互相挡」)。
- 两车 + 掩体三角夹住时:
separate_tanks(passes=3)仍可能留下 ≤1px 的亚像素重叠,靠下一帧继续分离;如果实测出现「贴着墙左右抖」,把TANK_GAP从 1px 放宽到 2px。一局结束后不再推挤,所以胜负已定时两车不会再被顶动。 - 掩体不可破坏:不重生、不随机;对局中途不能换图,只在等待开始时切
MAPS。 - 极端同帧互毁:双方最后一发子弹在同一帧互相命中时,胜负提示以后处理的那一方为准(两车都没命了,谁的名字出现算谁赢)。
- 兜底通道没有震动:原始 joystick 没有 rumble 通道;标准通道下被击毁那一方的手柄会震一下(
PAD_RUMBLE_MS = 180)。 - 十字键只当 hat 读:
Gamepad._hat()读的是joystick.get_hat(0),所以 SDL 把 dpad 映射成 hat 的手柄(Xbox 系列、Elite 等)十字键可用;映射成按钮(如老 Xbox 360 有线dpup:b11)的型号,十字键不参与转向,请用左摇杆。 - 单人模式下第二只手柄不参与:P2 由电脑控制,第二只手柄留给切回双人(F2)时用。
- 两只手柄 = 两台坦克:P1(红)= 第一只,P2(蓝)= 第二只;混插不同通道时标准通道那只固定排第一(见上文「两只不同型号能同时用吗」)。
- 蓝牙重连后设备 index 会变,游戏按
instance_id反查坦克并自动重建绑定,但归属仍按「重新接入的顺序」重排。 - EasySMX X05 这类 XInput/DInput 双模手柄在 PC 上要先切模式:按住
BACK+START约 3 秒,指示灯变蓝即 XInput;--pad-scan的输出里也会提示这一条。
python3.11 tank_game.py --1p --ai=normal # 或 --ai=easy / --ai=hard,游戏中 F3 随时切| 项目 | 数值 | 对打法的意义 |
|---|---|---|
| 击毁一次 | 10 发(DAMAGE=10 / MAX_HP=100) |
一局最多 50 发有效命中才够 |
| 开火冷却 | 18 帧 = 0.3 秒 | 双方都是 3.3 发/秒封顶,纯拼「谁的有效命中不断档」 |
| 炮弹速度 | 9 px/帧 = 540 px/s | 打 300px 外要飞 0.56 秒,足够被走位骗掉 |
| 你的速度 | 前进 3.2 px/帧,倒车 1.92 | — |
| 电脑速度 | easy 1.92 / normal 2.72 / hard 3.2;倒车只有 0.8 × 0.6 倍(hard 1.54) |
easy、normal 追不上你;hard 和你同速,跑不掉 |
| 电脑保持距离 | easy 180–340 / normal 140–300 / hard 120–280 | 太近它会倒车,太远它会直线压上来 |
| 电脑开火条件 | 瞄准误差 ≤ 9° / 6° / 4°,且距离 ≤ 560px,且炮口到你的视线不被掩体挡住 | 560px 外它一枪不开(实测几千发里 0 发超距);隔墙也不开——所以它一定会先绕过来找你 |
| 命中判定 | 圆:中心距 ≤ 22px(HIT_RADIUS 18 + 子弹半径 4) |
和车体朝向无关;擦着车边(>22px)飞过去不算命中 |
| 复活无敌 | 120 帧 = 2 秒 | 期间最多打 7 发(第 0/18/36/54/72/90/108 帧),子弹全被弹掉 |
- 开火键要一下一下敲。开火是上升沿,按住不放只出一枪——这是最容易白送的亏。
- 别贴脸打 hard。距离越近,双方的抖动和提前量误差影响越小,命中率一起飙升;而 hard 的 4° 容差 + 满提前量在近距离等于必中。保持 260–420px 打,命中率差距才站在你这边(不用目测:HUD 上的
DIST就是实时距离)。不过两车互斥之后,贴脸不再是免费的:最小中心距 44px,而且 AI 被顶住会倒车让开(照常瞄准开火),所以你顶上去它会让、不会站桩让你白打——DIST会发现它一直在退。 - 它开完火就换向。它的提前量只按你「位置差分」估平移速度,不管你转向;在它开枪后 1 秒内(300px 飞行时间)掉头/倒车,那发就打空,而它这 0.3 秒也不能再射——这就是你的进攻窗口。
- 复活那 2 秒是白送的进攻窗口。无敌期间它的子弹全被弹掉(实测它一局里约 1/3 的子弹都打在无敌壳上),而且复活点离它最远、炮口已经对着它,直接压上去把这 7 发打出去,血赚。
- 拉远到 560px 外是安全区。它不会开火,只会直线压过来——直线运动最好预判,等它进 400px 再对线。
- 它不会躲弹、不会横移;前进遇墙/掩体会停车转向绕行(不做寻路,可能顶在死角来回磨),但不会主动找掩体躲。所以把它顶到墙角杀不掉它,但能钉死它的位置、逼成站桩对枪;它唯一的脱离手段是倒车,只有 0.6 倍速。躲到柱子后面它不开枪,会先过来找你——柱子的价值是那几秒绕行时间,不是永久无敌点,别在柱子后面对枪。
另一个小优势:你的满舵转向也是 4°/帧(约 0.25 秒转 60°),而 hard 的转向增益是 1.25,误差小于 17.6° 时反而用不满舵(误差 10° 时只有 2.3°/帧)。小角度抢先手、甩炮口,你比它快一倍。细调则是你独有的:键盘点一下只走 1°(TURN_FINE_GAIN),AI 的抖动只有 ±2°,但它是靠容差开火,不会「点按微调」——所以 4° 以内的精修你比它便宜。
| 难度 | 瞄准抖动 | 开火容差 | 开火概率 | 提前量 | 抖动重掷间隔 |
|---|---|---|---|---|---|
| EASY | ±11° | 9° | 55% | 无 | 22 帧 |
| NORMAL | ±5.5° | 6° | 85% | 0.55 | 13 帧 |
| HARD | ±2° | 4° | 100% | 1.00 | 8 帧 |
拿 Tank / TankAI 真跑对局(每档 8 局,玩家是「完美瞄准 + 每 18 帧开一枪」的脚本,所以只看相对关系,别当真人命中率):
| 你的走法 | 电脑命中率 easy / normal / hard | 胜负(8 局)easy / normal / hard |
|---|---|---|
| 原地不动 | 52% / 89% / 94% | 8–0 / 8–0 / 8–0 |
| 贴脸缠斗(<160px) | 92% / 97% / 100% | 8–0 / 4–4 / 0–8 |
| 中距(260–420px)反复换向 | 73% / 79% / 82% | 8–0 / 7–1 / 4–4 |
三条结论:贴脸让双方都变准(对 hard 是找死)、中距换向能把它压到 82% 而你自己还有 86%(唯一对 hard 有效的走法)、easy 无论怎么打都是送的。
「贴脸缠斗」那一行的距离现在有硬下限:两车中心距最小 44px(
2 * TANK_HALF),站不进对方车里;而且被顶住时 AI 会倒车让开。也就是说「贴脸」从「可以钻进对方车体、以 0 距离对枪」变成了「44px 起、对方还在退」,这一行的绝对值要重跑才准。
这些走位数字来自
/tmp下的临时脚本(直接 importtank_game跑 headless 对局),没进仓库;评估的交叉验证脚本_sim_coach.py是进仓库的。注意:上表是在「一条命 5 发 + 包围盒判定」那版规则下测的。现在改成 10 发/条命 + 圆形判定后,电脑命中率会整体往下掉一截(
_sim_coach.py里 trained 对 HARD 的实测从 97% 掉到 89%),绝对值需要重跑才准,这里保留原始测量以便对比相对关系。
1p 模式下每局自动评分,打完在终端打一份详细报告、屏幕左下角出报告卡,并写进 tank_coach.json。目的不是记分,而是告诉你这一局具体错在哪、下一局该盯哪个数。
- F5 随时开关实时面板(局中就能看到当前的命中率、偏差、废弹数)。
- 局中触发某条纠正时,屏幕上方会挂一行 2.5 秒的提示(同一条一局只提示一次,不打断对局)。
python3.11 tank_game.py --coach-history只打历史战绩表,不开窗口。- 双人(2p)模式没有可比基准,不统计;
TANK_COACH=0可整体关掉。
| 指标 | 口径 | 用来纠正什么 |
|---|---|---|
| 命中率 | 命中 / 开火,护盾命中和超距弹都不算命中 | 整体准度 |
| 分档命中率 | 按开枪那一刻的距离(<120 / 120–280 / 280–420 / 420–560 / >560)和偏差(≤5° / 5–10° / 10–20° / >20°)分档 | 你在哪一段距离、多大偏差下打得准 |
| 废弹三类 | 超距(>560px)、打护盾(对方复活无敌)、按住空转(连按但冷却好了没出枪的帧) | 纪律 / 上升沿误用 |
| 冷却空转 | 「偏差 ≤5° + 射程内 + 冷却已好 + 对方不在无敌」却没开火的帧 ÷ 18 | 输出机会被浪费 |
| 站位 | 平均开火距离、贴身发数、原地不动的帧占比 | 距离控制 |
| 生存 | 被击毁次数、挨弹数、电脑对你的命中率、它开火后 12 帧内你换向的比例 | 走位与反应 |
| 输出 | 有效命中 / 秒 | 有效 DPS(不是开了几枪) |
五个分项按 准度 30% · 输出 25% · 纪律 20% · 生存 15% · 走位 10% 加权成 0–100,等级 S ≥ 85 / A ≥ 75 / B ≥ 60 / C ≥ 45 / D。
- 基准线(达到即满分):命中率 55%、有效输出 0.45 命中/秒(10 发击毁 ≈ 22 秒一次,所以这已经是「一直有伤害」的水平)、电脑命中率 35%。
- 开火少于 5 发时准度分按比例打折:靠 1 发 1 中就 100% 是不算数的。
- 纪律扣分:超距弹 / 护盾弹 / 按住空转 / 冷却空转,各自有上限,总共最多扣 100。
- 生存扣分:每次被击毁扣 14(5 条命全丢 = 70),电脑命中率超过 35% 的部分按 120 倍扣。
- 走位扣分:原地不动超过 40% 的部分按 120 倍扣,转向来回抖动再扣一点。
| code | 触发条件(都是本局实测数字) | 建议要点 |
|---|---|---|
held |
连按帧里 ≥15% 且 ≥12 帧「冷却好了却没出枪」 | 开火是上升沿,按住只打一枪;每 0.3 秒敲一下 |
far |
≥2 发且 ≥10% 在 560px 外开火 | 压进射程再打,否则子弹先出屏 |
shield |
打到对方无敌护盾 ≥1 发且占比 ≥8% | 它复活的白环消失前停火 |
aim |
开火平均偏差 ≥8°,且开火 ≥3 发 | 转到 5° 内再打(点按一格 1°,看 HUD 的 ERR 收进 ±2°) |
acc |
开火 ≥5 发但命中率 <35% | 宁可少打,等准星收进来 |
idle |
冷却空转折合 ≥3 发 | 到位就打,冷却空着等于白丢伤害 |
own_invuln |
复活过 ≥1 次但无敌期一发没打 | 复活瞬间就打,120 帧够打 7 发 |
deaths |
被击毁 ≥3 次 | 距离保持在 280–420px |
static |
原地不动 ≥60% 且射程内时间 ≥30% | 持续小幅换向,破坏它算的提前量 |
报告末尾还会给「做得好的地方」和下一局目标(取最弱的两个分项,写成带数字的目标,例如「命中率 41% → 55%」)。历史里还会显示最佳局和最近几局的命中率趋势(31% → 44% → 51%)。
_sim_coach.py 用真实物理跑三种「人类」打法,每档 6 局 vs HARD:
| 打法 | 均分 | 命中率 | 主要纠正 |
|---|---|---|---|
| naive:站桩 + 按住开火键不放 | 13(D) | 0% | 按住不放 / 浪费无敌 / 被击毁太频繁 / 站着不动 |
| spam:站桩 + 冷却一好就盲打 | 21(D) | 0% | 炮口没对准 / 命中率偏低 / 被击毁太频繁 |
| trained:按上文攻略打(280–420px、5° 内、一下一下敲、它开火后换向) | 78(A) | 93% | 冷却空转 / 被击毁太频繁 / 站着不动 |
同一局的报告示例(trained,改规则后的实测输出):
=== 本局评估 · HARD · 25.6s ===
综合 78/100 (A) 命中 22/23 = 96% 有效输出 0.86 命中/秒
开火纪律:超距 0 发 / 打护盾 0 发 / 按住空转 0 帧 / 冷却空转约 7 发
站位:平均 177px(贴身 3 发),原地不动 66%
生存:被击毁 5 次,挨 71 发(电脑命中率 89%),它开火后换向 100%
分项:准度 100 · 输出 100 · 纪律 86 · 生存 0 · 走位 54
注意 trained 仍然 0/6 全负、生存 0 分:脚本只会按固定套路走,换向救不了它被 hard 秒杀——这正好说明分数拆开看才有用(准度满分不等于赢)。
改前改后同一脚本的对照(都是 trained 第一局):局长 12.2s → 25.6s、一条命 2.4s → 5.1s、电脑命中率 97% → 89%、挨的子弹 37 发 → 71 发(活得更久,自然挨得更多)。
- 只在 1p 统计,双人对战没有基准,不记录。
- 「护盾命中」按命中那一刻判,「护盾弹占比」按开枪那一刻判: 6CD4 子弹飞行途中对方刚好复活这种边缘情况,会计一次护盾命中但占比为 0。
- 「它开火后换向」只看转向角,纯平移(前进/后退)不算反应。
- 点按不冤枉成「按住」:连续按住从第 2 帧起才计数;等对方无敌不算「空转」:对方无敌期间的待机不计入冷却空转。这两条是仿真里抓到误报后专门修的,
_selftest_coach.py有回归。 - 被掩体挡下的子弹算 miss:评估只认「有没有打到坦克」,隔墙打出去(或擦着方块被打掉)的子弹不计命中,所以对着掩体乱打的局准度分会偏低——这跟「废弹」口径是一致的。
- 评估不评判「没开火但走位好」这类无法量化的事,也不给操作手感打分。
仓库里的 _selftest*.py / _preview.py / _sim_coach.py 是开发态自测脚本(打包时已被 packaging/windows/tank_game.spec 排除)。它们大多是「顶层断言 + import 副作用」,且 tank_game 在 import 时就开窗口,所以统一用子进程跑,并自动注入 SDL_VIDEODRIVER=dummy / SDL_AUDIODRIVER=dummy。
# 1) 装依赖(pygame>=2.5,<3,与 packaging/windows/requirements-build.txt 对齐)
pip install -r requirements.txt
# 2) 一键跑全部自测(末尾打印「通过 N / 失败 M」,任一失败退出码非 0)
python run_selftests.py
# 列出全部脚本与说明(不执行)
python run_selftests.py --list
# 跳过耗时较长的 AI / coach 回归(_selftest_ai / _sim_coach / _selftest_coach)
python run_selftests.py --quick
# 只跑指定脚本
python run_selftests.py _selftest_body.py _selftest_walls.py说明:
- 解释器:本机用
python3.11(README 各处示例的版本)。run_selftests.py用「运行它的那个解释器」去跑子进程,所以python3.11 run_selftests.py即可。 - 为什么用子进程:
_selftest_font.py必须只在__main__下运行(子进程import它会无限递归),且各脚本靠 import 副作用执行断言,子进程隔离是唯一安全方式。 - CI:
.github/workflows/ci.yml在 ubuntu / macos / windows × Python 3.11 / 3.13 矩阵上跑python run_selftests.py,并单独跑ruff check .。跨三平台是为了覆盖tank_paths.py的LOCALAPPDATA/~/.local/share分支与tank_game.py的三平台字体候选路径。 - Lint:
pip install -r requirements-dev.txt && ruff check .(规则集见pyproject.toml的[tool.ruff],故意不启用格式检查)。 - 单个脚本手动跑:仍可按各脚本 docstring 里的命令单独跑,例如
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy python3.11 _selftest_ai.py。
可以上架 Microsoft Store,但不能直接提交 .py。路径:
- Windows 上跑
packaging/windows/build.ps1→dist\TankGame\TankGame.exe smoke_test.ps1确认存档进%LOCALAPPDATA%\TankGame\build_msix.ps1打 MSIX(需 Windows SDK)- Partner Center 提交(步骤与清单见
packaging/windows/STORE.md)
无 Windows 时可跑 ./packaging/windows/build_mac_smoke.sh 验证冻结态数据目录逻辑。