8000 GitHub - eppen/tank · GitHub
[go: up one dir, main page]

Skip to content
eppenPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

31 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

双履 Twin Treads(tank_game)

文件: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 条命)

  • 每台坦克 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 + 静止靶」的单向场景,故意不接分离)。

手柄支持

游戏里有两条手柄通道,启动时自动选择:

  1. SDL 标准通道(首选):手柄在 SDL 内置映射库里查得到,就按语义读取(左摇杆、ABXY、模拟扳机、Start/Back),不用记轴号按钮号,插上即可用。
  2. 原始 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 就是微软 VID 045e 的字节序。仿品/翻新常被改成别的 VID,正好落进兜底通道——能玩,但要调参。

两只不同型号能同时用吗:能,两只手柄各走各的通道、各自算上升沿,互不干扰(同型号更是没问题)。要注意三点:

  1. 槽位顺序不是纯插入顺序:先排所有 SDL 认得的(标准通道),再排兜底通道的。所以「一只标准 + 一只杂牌」时,标准那只固定是 P1(红),杂牌那只固定是 P2(蓝),跟谁先插无关。
  2. 调参开关是全局的: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 映射库的型号。
  3. 震动只有标准通道有:兜底那只不震,标准那只正常震。

买之前先跑一遍 --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 官方软件映射成任意键再用。

转向:4°/帧 与点按微转

  • 满舵转速: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 帧),子弹全被弹掉

六条能直接用的

  1. 开火键要一下一下敲。开火是上升沿,按住不放只出一枪——这是最容易白送的亏。
  2. 别贴脸打 hard。距离越近,双方的抖动和提前量误差影响越小,命中率一起飙升;而 hard 的 4° 容差 + 满提前量在近距离等于必中。保持 260–420px 打,命中率差距才站在你这边(不用目测:HUD 上的 DIST 就是实时距离)。不过两车互斥之后,贴脸不再是免费的:最小中心距 44px,而且 AI 被顶住会倒车让开(照常瞄准开火),所以你顶上去它会让、不会站桩让你白打——DIST 会发现它一直在退。
  3. 它开完火就换向。它的提前量只按你「位置差分」估平移速度,不管你转向;在它开枪后 1 秒内(300px 飞行时间)掉头/倒车,那发就打空,而它这 0.3 秒也不能再射——这就是你的进攻窗口。
  4. 复活那 2 秒是白送的进攻窗口。无敌期间它的子弹全被弹掉(实测它一局里约 1/3 的子弹都打在无敌壳上),而且复活点离它最远、炮口已经对着它,直接压上去把这 7 发打出去,血赚。
  5. 拉远到 560px 外是安全区。它不会开火,只会直线压过来——直线运动最好预判,等它进 400px 再对线。
  6. 它不会躲弹、不会横移;前进遇墙/掩体会停车转向绕行(不做寻路,可能顶在死角来回磨),但不会主动找掩体躲。所以把它顶到墙角杀不掉它,但能钉死它的位置、逼成站桩对枪;它唯一的脱离手段是倒车,只有 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 下的临时脚本(直接 import tank_game 跑 headless 对局),没进仓库;评估的交叉验证脚本 _sim_coach.py 是进仓库的。

注意:上表是在「一条命 5 发 + 包围盒判定」那版规则下测的。现在改成 10 发/条命 + 圆形判定后,电脑命中率会整体往下掉一截(_sim_coach.py 里 trained 对 HARD 的实测从 97% 掉到 89%),绝对值需要重跑才准,这里保留原始测量以便对比相对关系。

表现评估与纠正(coach)

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 / Windows 打包

可以上架 Microsoft Store,但不能直接提交 .py。路径:

  1. Windows 上跑 packaging/windows/build.ps1 → dist\TankGame\TankGame.exe
  2. smoke_test.ps1 确认存档进 %LOCALAPPDATA%\TankGame\
  3. build_msix.ps1 打 MSIX(需 Windows SDK)
  4. Partner Center 提交(步骤与清单见 packaging/windows/STORE.md)

无 Windows 时可跑 ./packaging/windows/build_mac_smoke.sh 验证冻结态数据目录逻辑。

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0