dsh-eval-guard 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +91 -13
  2. package/assets/commit0/instance.json +30 -0
  3. package/assets/commit0/workspace.zip +0 -0
  4. package/assets/featurebench/hidden/test_regression.py +690 -0
  5. package/assets/featurebench/instance.json +16 -0
  6. package/assets/featurebench/workspace.zip +0 -0
  7. package/assets/swe-bench-pro/hidden/test.patch +36 -0
  8. package/assets/swe-bench-pro/instance.json +10 -0
  9. package/assets/swe-bench-pro/workspace.zip +0 -0
  10. package/eval/README.md +18 -0
  11. package/eval/arena/arena/README.md +21 -0
  12. package/eval/arena/arena/bt.py +104 -0
  13. package/eval/arena/arena/clock.py +15 -0
  14. package/eval/arena/arena/core.py +265 -0
  15. package/eval/arena/arena/run.cmd +24 -0
  16. package/eval/arena/arena/run.sh +11 -0
  17. package/eval/arena/arena/schedule.py +79 -0
  18. package/eval/arena/arena/tournament.py +183 -0
  19. package/eval/arena/bomberman/bots/README.md +16 -0
  20. package/eval/arena/bomberman/bots/deepseek-v4.1-flash.cpp +447 -0
  21. package/eval/arena/bomberman/bots/doubao-seed-2.1.cpp +542 -0
  22. package/eval/arena/bomberman/bots/glm-5.3.cpp +406 -0
  23. package/eval/arena/bomberman/bots/gpt-6-astra.cpp +768 -0
  24. package/eval/arena/bomberman/bots/gpt-6-sol.cpp +444 -0
  25. package/eval/arena/bomberman/bots/kimi-k3.cpp +432 -0
  26. package/eval/arena/bomberman/bots/mimo-v2.6-pro.cpp +529 -0
  27. package/eval/arena/bomberman/bots/opus-5.5.cpp +548 -0
  28. package/eval/arena/bomberman/bots/qwen-3.8-max.cpp +836 -0
  29. package/eval/arena/bomberman/tournament/README.md +11 -0
  30. package/eval/arena/bomberman/tournament/bt.py +56 -0
  31. package/eval/arena/bomberman/tournament/evaluate.py +199 -0
  32. package/eval/arena/bomberman/tournament/run.py +79 -0
  33. package/eval/arena/bomberman/tournament/schedule-2to10.json.gz +0 -0
  34. package/eval/arena/bomberman/workspace/MANIFEST.json +13 -0
  35. package/eval/arena/bomberman/workspace/README.md +29 -0
  36. package/eval/arena/bomberman/workspace/RULES.md +100 -0
  37. package/eval/arena/bomberman/workspace/baseline.cpp +240 -0
  38. package/eval/arena/bomberman/workspace/clock.py +25 -0
  39. package/eval/arena/bomberman/workspace/engine/arena.py +198 -0
  40. package/eval/arena/bomberman/workspace/engine/build.py +35 -0
  41. package/eval/arena/bomberman/workspace/engine/engine.py +256 -0
  42. package/eval/arena/bomberman/workspace/engine/verify_replay.py +24 -0
  43. package/eval/arena/bomberman/workspace/run.cmd +28 -0
  44. package/eval/arena/bomberman/workspace/run.sh +16 -0
  45. package/eval/arena/bomberman/workspace/viewer/index.html +42 -0
  46. package/eval/arena/ctf/bots/README.md +18 -0
  47. package/eval/arena/ctf/bots/deepseek-v4.1-flash.cpp +601 -0
  48. package/eval/arena/ctf/bots/doubao-seed-2.1-turbo.cpp +320 -0
  49. package/eval/arena/ctf/bots/fable-5.1.cpp +634 -0
  50. package/eval/arena/ctf/bots/glm-5.3.cpp +689 -0
  51. package/eval/arena/ctf/bots/gpt-6-astra.cpp +657 -0
  52. package/eval/arena/ctf/bots/gpt-6.1-sol.cpp +725 -0
  53. package/eval/arena/ctf/bots/kimi-k3.cpp +667 -0
  54. package/eval/arena/ctf/bots/mimo-v2.6-pro.cpp +533 -0
  55. package/eval/arena/ctf/bots/opus-5.5.cpp +645 -0
  56. package/eval/arena/ctf/bots/qwen-3.8-max.cpp +726 -0
  57. package/eval/arena/ctf/bots/sonnet-5.5.cpp +569 -0
  58. package/eval/arena/ctf/tournament/README.md +15 -0
  59. package/eval/arena/ctf/tournament/formal-2to8.json +31 -0
  60. package/eval/arena/ctf/tournament/formal-9to15.json +31 -0
  61. package/eval/arena/ctf/workspace/README.md +41 -0
  62. package/eval/arena/ctf/workspace/RULES.md +119 -0
  63. package/eval/arena/ctf/workspace/arena.py +186 -0
  64. package/eval/arena/ctf/workspace/baseline.cpp +257 -0
  65. package/eval/arena/ctf/workspace/clock.py +15 -0
  66. package/eval/arena/ctf/workspace/core.py +265 -0
  67. package/eval/arena/ctf/workspace/engine.py +389 -0
  68. package/eval/arena/ctf/workspace/run.cmd +24 -0
  69. package/eval/arena/ctf/workspace/run.sh +11 -0
  70. package/eval/arena/ctf/workspace/viewer.html +71 -0
  71. package/eval/arena/land/bots/README.md +17 -0
  72. package/eval/arena/land/bots/deepseek-4.1f@workbuddy#1/bot.cpp +608 -0
  73. package/eval/arena/land/bots/glm-5.3@workbuddy#1/bot.cpp +521 -0
  74. package/eval/arena/land/bots/gpt-6-astra@codex#1/bot.cpp +373 -0
  75. package/eval/arena/land/bots/gpt-6-sol@codex#1/bot.cpp +341 -0
  76. package/eval/arena/land/bots/kimi-k3@workbuddy#1/bot.cpp +492 -0
  77. package/eval/arena/land/bots/mimo-2.6-pro@zcode#1/bot.cpp +652 -0
  78. package/eval/arena/land/bots/mystery-model@zcode#1/bot.cpp +466 -0
  79. package/eval/arena/land/bots/opus-5.5@claude-code#1/bot.cpp +630 -0
  80. package/eval/arena/land/bots/sonnet-5@claude-code#1/bot.cpp +619 -0
  81. package/eval/arena/land/tournament/make_replay_page.py +17 -0
  82. package/eval/arena/land/tournament/rate.py +290 -0
  83. package/eval/arena/land/tournament/round_robin.py +172 -0
  84. package/eval/arena/land/tournament/sparring.cpp +356 -0
  85. package/eval/arena/land/workspace/RULES.md +114 -0
  86. package/eval/arena/land/workspace/TASK.md +46 -0
  87. package/eval/arena/land/workspace/anchors/.build/greedy_f2e6d2dea5ca +0 -0
  88. package/eval/arena/land/workspace/anchors/greedy.cpp +194 -0
  89. package/eval/arena/land/workspace/anchors/random.cpp +80 -0
  90. package/eval/arena/land/workspace/clock.py +64 -0
  91. package/eval/arena/land/workspace/engine/build.py +33 -0
  92. package/eval/arena/land/workspace/engine/game.py +273 -0
  93. package/eval/arena/land/workspace/engine/referee.py +308 -0
  94. package/eval/arena/land/workspace/starter/.build/bot_be32b45b4699 +0 -0
  95. package/eval/arena/land/workspace/starter/bot.cpp +82 -0
  96. package/eval/arena/land/workspace/viewer/viewer.html +210 -0
  97. package/eval/arena/tank/bots/README.md +24 -0
  98. package/eval/arena/tank/bots/half1/deepseek-v4.1-flash.cpp +367 -0
  99. package/eval/arena/tank/bots/half1/doubao-seed-evolving.cpp +402 -0
  100. package/eval/arena/tank/bots/half1/gemini-4-argon.cpp +1132 -0
  101. package/eval/arena/tank/bots/half1/glm-5.3.cpp +551 -0
  102. package/eval/arena/tank/bots/half1/gpt-6-astra.cpp +233 -0
  103. package/eval/arena/tank/bots/half1/gpt-6.1-sol-ultrafast.cpp +350 -0
  104. package/eval/arena/tank/bots/half1/gpt-6.1-sol.cpp +78 -0
  105. package/eval/arena/tank/bots/half1/grok-4.7.cpp +1099 -0
  106. package/eval/arena/tank/bots/half1/haiku-5.5.cpp +210 -0
  107. package/eval/arena/tank/bots/half1/kimi-k3.cpp +353 -0
  108. package/eval/arena/tank/bots/half1/mimo-v2.6-pro.cpp +336 -0
  109. package/eval/arena/tank/bots/half1/muse-wonder-full.cpp +560 -0
  110. package/eval/arena/tank/bots/half1/opus-5.5.cpp +435 -0
  111. package/eval/arena/tank/bots/half1/qwen-3.8-max.cpp +537 -0
  112. package/eval/arena/tank/bots/half2/deepseek-v4.1-flash.cpp +425 -0
  113. package/eval/arena/tank/bots/half2/doubao-seed-evolving.cpp +308 -0
  114. package/eval/arena/tank/bots/half2/gemini-4-argon.cpp +1390 -0
  115. package/eval/arena/tank/bots/half2/glm-5.3.cpp +651 -0
  116. package/eval/arena/tank/bots/half2/gpt-6-astra.cpp +241 -0
  117. package/eval/arena/tank/bots/half2/gpt-6.1-sol-ultrafast.cpp +356 -0
  118. package/eval/arena/tank/bots/half2/gpt-6.1-sol.cpp +87 -0
  119. package/eval/arena/tank/bots/half2/grok-4.7.cpp +1205 -0
  120. package/eval/arena/tank/bots/half2/haiku-5.5.cpp +210 -0
  121. package/eval/arena/tank/bots/half2/kimi-k3.cpp +487 -0
  122. package/eval/arena/tank/bots/half2/mimo-v2.6-pro.cpp +336 -0
  123. package/eval/arena/tank/bots/half2/muse-wonder-full.cpp +560 -0
  124. package/eval/arena/tank/bots/half2/opus-5.5.cpp +476 -0
  125. package/eval/arena/tank/bots/half2/qwen-3.8-max.cpp +392 -0
  126. package/eval/arena/tank/tournament/README.md +24 -0
  127. package/eval/arena/tank/tournament/bt.py +218 -0
  128. package/eval/arena/tank/tournament/final.json +202 -0
  129. package/eval/arena/tank/tournament/halftime.json +146 -0
  130. package/eval/arena/tank/tournament/progress.json +911 -0
  131. package/eval/arena/tank/tournament/schedule.py +100 -0
  132. package/eval/arena/tank/tournament/sparring.cpp +928 -0
  133. package/eval/arena/tank/tournament/tournament.py +1426 -0
  134. package/eval/arena/tank/workspace/README.md +169 -0
  135. package/eval/arena/tank/workspace/RULES.md +443 -0
  136. package/eval/arena/tank/workspace/arena.py +862 -0
  137. package/eval/arena/tank/workspace/baseline.cpp +259 -0
  138. package/eval/arena/tank/workspace/clock.py +103 -0
  139. package/eval/arena/tank/workspace/core.py +712 -0
  140. package/eval/arena/tank/workspace/engine.py +573 -0
  141. package/eval/arena/tank/workspace/history/.lock +0 -0
  142. package/eval/arena/tank/workspace/history/20261017-193610-08715287f91f-bot.cpp +109 -0
  143. package/eval/arena/tank/workspace/history/20261017-193619-f00b2d5635fa-gpt-6-astra.cpp +241 -0
  144. package/eval/arena/tank/workspace/history/20261017-193643-63319e0a2b1a-gpt-6_1-sol-ultrafast.cpp +356 -0
  145. package/eval/arena/tank/workspace/history/20261017-193647-41c1341d0a8a-gemini-4-argon.cpp +1390 -0
  146. package/eval/arena/tank/workspace/history/20261017-193652-e34c0f45539a-gpt-6_1-sol.cpp +87 -0
  147. package/eval/arena/tank/workspace/history/20261017-193654-c026348ce96e-opus-5_5.cpp +476 -0
  148. package/eval/arena/tank/workspace/history/20261017-193655-4a21366e94d1-muse-wonder-full.cpp +560 -0
  149. package/eval/arena/tank/workspace/history/20261017-193657-dbc4e9969c52-mimo-v2_6-pro.cpp +336 -0
  150. package/eval/arena/tank/workspace/history/20261017-193703-723cd036eb31-grok-4_7.cpp +1205 -0
  151. package/eval/arena/tank/workspace/history/20261017-193707-1ebbb227be68-deepseek-v4_1-flash.cpp +425 -0
  152. package/eval/arena/tank/workspace/history/20261017-193709-aeb0e7202ad5-doubao-seed-evolving.cpp +308 -0
  153. package/eval/arena/tank/workspace/history/20261017-193710-b21ebeb9bc76-kimi-k3.cpp +487 -0
  154. package/eval/arena/tank/workspace/history/20261017-193712-54cd86531905-haiku-5_5.cpp +210 -0
  155. package/eval/arena/tank/workspace/history/20261017-193714-efdb8e35c0bf-qwen-3_8-max.cpp +392 -0
  156. package/eval/arena/tank/workspace/history/20261017-193715-681423c23fdb-glm-5_3.cpp +651 -0
  157. package/eval/arena/tank/workspace/history/20261017-193717-1a8b3c75181d-sparring.cpp +928 -0
  158. package/eval/arena/tank/workspace/history/20261017-193720-1e0803a541e8-baseline.cpp +259 -0
  159. package/eval/arena/tank/workspace/history/log.jsonl +103 -0
  160. package/eval/arena/tank/workspace/judge/cycles.hpp +53 -0
  161. package/eval/arena/tank/workspace/judge/fast.cpp +337 -0
  162. package/eval/arena/tank/workspace/judge/referee.cpp +125 -0
  163. package/eval/arena/tank/workspace/judge/speed.cpp +99 -0
  164. package/eval/arena/tank/workspace/run.cmd +31 -0
  165. package/eval/arena/tank/workspace/run.sh +23 -0
  166. package/eval/arena/tank/workspace/tank.hpp +1762 -0
  167. package/eval/arena/tank/workspace/tank_main.cpp +215 -0
  168. package/eval/arena/tank/workspace/viewer.html +1150 -0
  169. package/eval/arena/warehouse/workspace/SPEC.md +130 -0
  170. package/eval/arena/warehouse/workspace/TASK.md +71 -0
  171. package/eval/arena/warehouse/workspace/baseline/common.h +88 -0
  172. package/eval/arena/warehouse/workspace/baseline/main.cpp +27 -0
  173. package/eval/arena/warehouse/workspace/baseline/old_stuff.cpp +87 -0
  174. package/eval/arena/warehouse/workspace/baseline/sim.cpp +427 -0
  175. package/eval/arena/warehouse/workspace/baseline/util.cpp +220 -0
  176. package/eval/arena/warehouse/workspace/clock.py +42 -0
  177. package/eval/arena/warehouse/workspace/src/common.h +88 -0
  178. package/eval/arena/warehouse/workspace/src/main.cpp +27 -0
  179. package/eval/arena/warehouse/workspace/src/old_stuff.cpp +87 -0
  180. package/eval/arena/warehouse/workspace/src/sim.cpp +427 -0
  181. package/eval/arena/warehouse/workspace/src/util.cpp +220 -0
  182. package/eval/arena/warehouse/workspace/tools/bench.py +119 -0
  183. package/eval/arena/warehouse/workspace/tools/check_input.py +161 -0
  184. package/eval/arena/warehouse/workspace/tools/gen.py +132 -0
  185. package/eval/arena/warehouse/workspace/tools/measure.py +107 -0
  186. package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.in +1420 -0
  187. package/eval/arena/warehouse/workspace/workloads/public/pub_crowded.out +9162 -0
  188. package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.in +1465 -0
  189. package/eval/arena/warehouse/workspace/workloads/public/pub_dynamic.out +3439 -0
  190. package/eval/arena/warehouse/workspace/workloads/public/pub_flood.in +2757 -0
  191. package/eval/arena/warehouse/workspace/workloads/public/pub_flood.out +3126 -0
  192. package/eval/arena/warehouse/workspace/workloads/public/pub_small.in +731 -0
  193. package/eval/arena/warehouse/workspace/workloads/public/pub_small.out +2578 -0
  194. package/eval/arena/warehouse/workspace/workloads/public/pub_wide.in +528 -0
  195. package/eval/arena/warehouse/workspace/workloads/public/pub_wide.out +2026 -0
  196. package/eval/bomberman.py +20 -0
  197. package/eval/commit0.py +21 -0
  198. package/eval/common.py +61 -0
  199. package/eval/ctf.py +20 -0
  200. package/eval/featurebench.py +25 -0
  201. package/eval/land.py +20 -0
  202. package/eval/legacy/bomberman-prices.json +4 -0
  203. package/eval/legacy/bomberman.py +161 -0
  204. package/eval/legacy/commit0.py +91 -0
  205. package/eval/legacy/ctf-prices.json +4 -0
  206. package/eval/legacy/ctf.py +178 -0
  207. package/eval/legacy/featurebench.py +103 -0
  208. package/eval/legacy/land-prices.json +4 -0
  209. package/eval/legacy/land.py +177 -0
  210. package/eval/legacy/swe_bench_pro.py +116 -0
  211. package/eval/legacy/tank-prices.json +3 -0
  212. package/eval/legacy/tank.py +408 -0
  213. package/eval/legacy/warehouse-prices.json +4 -0
  214. package/eval/legacy/warehouse.py +365 -0
  215. package/eval/swe-bench-pro.py +25 -0
  216. package/eval/tank.py +20 -0
  217. package/eval/warehouse.py +20 -0
  218. package/lib/client/index.js +376 -63
  219. package/lib/client/index.js.map +1 -1
  220. package/lib/index.js +684 -27
  221. package/package.json +2 -1
@@ -0,0 +1,1150 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>坦克大战回放</title>
7
+ <!--
8
+ 坦克大战回放查看器。单个文件,不依赖任何外部资源,双击用浏览器打开。
9
+ 数据:arena.py expand 展开出的逐帧 JSON(tank-frames-1)。三种来法:
10
+ 1. arena.py play / arena.py html 生成的 HTML 里数据已经嵌好,打开就能看;
11
+ 2. 把 *.frames.json 拖进页面;
12
+ 3. 点右上角"打开文件"。
13
+ 地址栏参数(?turn=12&p=0.5 这种写法,# 后面也行)见页面里的"帮助"。
14
+ -->
15
+ <style>
16
+ :root{
17
+ color-scheme:dark;
18
+ --bg:#0c0e11;--panel:#151920;--panel2:#1b2029;--line:#2a313c;--ink:#e9edf3;--ink2:#b3bcc8;--muted:#7c8794;
19
+ --c0:#d47a1a;--c0l:#f6b25c;--c1:#3d8fe8;--c1l:#9ccbff;--gold:#ffd24a;--bad:#ff5a4d;--warn:#f5c542;--good:#62d08a;
20
+ --board:600px;
21
+ }
22
+ *{box-sizing:border-box}
23
+ html,body{height:100%;margin:0}
24
+ body{background:var(--bg);color:var(--ink);font-family:"Segoe UI","Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans CJK SC",sans-serif;
25
+ font-size:clamp(12px,0.84vw,16px);overflow:hidden;-webkit-font-smoothing:antialiased}
26
+ body.narrow{overflow:auto}
27
+ #app{display:grid;grid-template-rows:auto minmax(0,1fr) auto;height:100vh}
28
+ #app>header{grid-row:1}#app>main{grid-row:2}#app>footer{grid-row:3}
29
+ body.narrow #app{height:auto;min-height:100vh}
30
+ b,strong{font-weight:600}
31
+ .mono{font-family:Consolas,"Cascadia Mono","Courier New",monospace}
32
+
33
+ header{display:flex;align-items:center;gap:1em;padding:.5em 1em;background:#101318;border-bottom:1px solid var(--line);min-height:3em}
34
+ header h1{margin:0;font-size:1.25em;letter-spacing:.08em;white-space:nowrap}
35
+ header h1 small{font-weight:400;color:var(--muted);font-size:.72em;letter-spacing:0;margin-left:.6em}
36
+ #meta{color:var(--ink2);font-size:.9em;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
37
+ #meta .warnchip{color:var(--bg);background:var(--warn);border-radius:.3em;padding:.05em .45em;margin-left:.6em;font-weight:600}
38
+ button,select{background:#232a35;color:var(--ink);border:1px solid #3a4452;border-radius:.4em;padding:.4em .75em;font:inherit;font-size:.92em;cursor:pointer;line-height:1.2}
39
+ button:hover,select:hover{background:#2e3745}
40
+ button:focus-visible,select:focus-visible{outline:2px solid var(--gold);outline-offset:1px}
41
+
42
+ main{display:grid;grid-template-columns:minmax(0,1fr) var(--board) minmax(0,1fr);gap:.75em;padding:.75em;min-height:0}
43
+ body.narrow main{display:flex;flex-direction:column;align-items:stretch}
44
+ aside{display:flex;flex-direction:column;gap:.75em;min-height:0;min-width:0;overflow:auto;scrollbar-width:thin}
45
+ aside>.team{flex:none}
46
+ body.narrow aside{overflow:visible}
47
+ body.narrow #stage{order:-1}
48
+ #stage{position:relative;width:var(--board);height:var(--board);align-self:center;justify-self:center;flex:none}
49
+ body.narrow #stage{margin:0 auto}
50
+ #board{width:100%;height:100%;display:block;border-radius:.4em}
51
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:.6em;padding:.7em .85em;min-height:0;min-width:0}
52
+ .panel h2{margin:0 0 .5em;font-size:.82em;font-weight:600;color:var(--muted);letter-spacing:.12em;display:flex;justify-content:space-between;gap:.6em;align-items:baseline}
53
+ .panel h2 span{letter-spacing:0;font-weight:400}
54
+
55
+ /* ---- 队伍卡片 ---- */
56
+ .team{border-left:.4em solid var(--tc);position:relative}
57
+ .team.s0{--tc:var(--c0);--tcl:var(--c0l)}
58
+ .team.s1{--tc:var(--c1);--tcl:var(--c1l)}
59
+ .thead{display:flex;align-items:center;gap:.6em;margin-bottom:.5em}
60
+ .mark{width:1.15em;height:1.15em;flex:none;border-radius:.2em;background:var(--tc);border:2px solid var(--tcl)}
61
+ .s1 .mark,.mark.s1{background:repeating-linear-gradient(135deg,var(--c1) 0 3px,#0d1626 3px 6px);border-color:var(--c1l)}
62
+ .mark.s0{background:var(--c0);border-color:var(--c0l)}
63
+ .tname{font-size:1.5em;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
64
+ .tseat{color:var(--muted);font-size:.85em;white-space:nowrap;margin-left:auto}
65
+ .tres{font-size:.85em;font-weight:700;padding:.1em .55em;border-radius:.3em;white-space:nowrap}
66
+ .tflt{font-size:.8em;font-weight:700;padding:.1em .5em;border-radius:.3em;white-space:nowrap;background:var(--bad);color:#fff}.tflt:empty{display:none}
67
+ .tres.win{background:var(--gold);color:#1a1300}
68
+ .tres.lose{background:#333b47;color:var(--ink2)}
69
+ .tres.draw{background:#333b47;color:var(--ink)}
70
+ .baserow{display:flex;align-items:center;gap:.6em;margin-bottom:.5em}
71
+ .baserow .lbl{color:var(--ink2);font-size:.88em;white-space:nowrap}
72
+ .hpbar{flex:1;display:flex;gap:2px;height:1.05em}
73
+ .hpbar i{flex:1;background:#262d38;border-radius:2px}
74
+ .hpbar i.on{background:var(--tc)}
75
+ .hpbar i.hit{background:#fff}
76
+ .hpnum{font-size:1.5em;font-weight:700;min-width:2.6em;text-align:right;font-family:Consolas,monospace}
77
+ .hpnum small{font-size:.6em;color:var(--muted);font-weight:400}
78
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.4em;margin-bottom:.5em}
79
+ .stats div{background:var(--panel2);border-radius:.35em;padding:.25em .5em;min-width:0}
80
+ .stats b{display:block;font-size:1.2em;font-family:Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
81
+ .stats span{display:block;font-size:.72em;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
82
+ .stats .near b{color:var(--warn)}.stats .over b{color:var(--bad)}
83
+ .trow{display:grid;grid-template-columns:3.1em minmax(0,1fr) auto;gap:.6em;align-items:center;padding:.3em 0;border-top:1px solid #222935}
84
+ .trow canvas{width:3.1em;height:3.1em;display:block;background:#0e1014;border-radius:.3em}
85
+ .trow.dead canvas{opacity:.35}
86
+ .tl1{display:flex;align-items:baseline;gap:.5em;white-space:nowrap}
87
+ .tl1 b{font-size:1.05em}
88
+ .tl1 .pos{color:var(--muted);font-size:.85em;font-family:Consolas,monospace}
89
+ .pips{display:inline-flex;gap:2px;vertical-align:middle}
90
+ .pips i{width:.7em;height:.55em;border:1px solid var(--good);border-radius:1px}
91
+ .pips i.on{background:var(--good)}
92
+ .lv{color:var(--gold);font-size:.85em;letter-spacing:.05em}
93
+ .lv.z{color:var(--muted)}
94
+ .tl2{color:var(--ink2);font-size:.82em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
95
+ .tl2 .rel{color:var(--warn)}.tl2 .ded{color:var(--bad)}
96
+ .ord{font-size:.95em;background:var(--panel2);border:1px solid #333c49;border-radius:.35em;padding:.25em .6em;white-space:nowrap;min-width:5.6em;text-align:center}
97
+ .ord .ar{font-family:"Segoe UI Symbol",Consolas,monospace;font-weight:700;color:var(--tcl);font-size:1.1em}
98
+ .ord.none{color:var(--muted);border-style:dashed;background:none}
99
+ .ord .x{color:var(--bad);font-weight:700}
100
+ .ord small{color:var(--muted)}
101
+
102
+ /* ---- 中间:回合信息 ---- */
103
+ #turnbox{flex:1 0 auto;display:flex;flex-direction:column;justify-content:center;gap:.55em;text-align:center}
104
+ #turnbig{font-size:2.6em;font-weight:700;line-height:1;font-family:Consolas,"Microsoft YaHei UI","Microsoft YaHei",sans-serif}
105
+ #turnbig small{font-size:.42em;color:var(--muted);font-weight:400;font-family:inherit}
106
+ #phases{display:flex;gap:.35em;justify-content:center}
107
+ #phases i{font-style:normal;font-size:.8em;padding:.15em .7em;border-radius:1em;background:var(--panel2);color:var(--muted);border:1px solid transparent}
108
+ #phases i.on{color:#111;background:var(--gold)}
109
+ #supplyline{color:var(--ink2);font-size:.92em}
110
+ #supplyline b{color:var(--gold);font-family:Consolas,monospace;font-size:1.15em}
111
+ #legend{width:100%;display:block}
112
+ @media (max-height:880px){body:not(.narrow) #legend{display:none}}
113
+
114
+ /* ---- 右栏 ---- */
115
+ #evpanel{flex:2.3;display:flex;flex-direction:column}
116
+ #events{flex:1;overflow:auto;font-size:.95em;line-height:1.62;min-height:3em}
117
+ #events .grp{color:var(--muted);font-size:.78em;letter-spacing:.1em;margin-top:.35em;border-bottom:1px solid #222935}
118
+ #events .ev{display:flex;gap:.5em;align-items:baseline}
119
+ #events .ev .ic{width:1.2em;text-align:center;flex:none;font-weight:700}
120
+ #events .ev.big{font-weight:600}
121
+ #events .none{color:var(--muted)}
122
+ .tk{white-space:nowrap;font-weight:600}
123
+ .tk i{display:inline-block;width:.62em;height:.62em;margin-right:.25em;border-radius:1px;vertical-align:baseline}
124
+ .tk.s0{color:var(--c0l)}.tk.s0 i{background:var(--c0l)}
125
+ .tk.s1{color:var(--c1l)}.tk.s1 i{border:2px solid var(--c1l);border-radius:50%}
126
+ .c-bad{color:var(--bad)}.c-gold{color:var(--gold)}.c-mut{color:var(--muted)}.c-ink2{color:var(--ink2)}
127
+ #thinkpanel{flex:none;position:relative}
128
+ #think{width:100%;height:11.5em;display:block;cursor:pointer}
129
+ #thinknote{font-size:.76em;color:var(--muted);margin-top:.3em;line-height:1.5}
130
+ #dbgpanel{flex:1;display:flex;flex-direction:column}
131
+ #debug{flex:1;overflow:auto;font-size:.85em;line-height:1.6;min-height:3em}
132
+ #debug .fault{color:var(--bad);cursor:pointer}
133
+ #debug .fault:hover{text-decoration:underline}
134
+ #debug .fault.now{font-weight:700}
135
+ #debug .ln{font-family:Consolas,"Microsoft YaHei",monospace;white-space:pre-wrap;word-break:break-all}
136
+ #debug .sub{color:var(--muted);font-size:.9em;margin-top:.3em}
137
+
138
+ /* ---- 底部控制条 ---- */
139
+ footer{display:flex;align-items:center;gap:.8em;padding:.55em 1em .65em;background:#101318;border-top:1px solid var(--line)}
140
+ footer .btns{display:flex;gap:.3em;flex:none}
141
+ footer button{min-width:2.6em;font-size:1em;padding:.5em .6em}
142
+ #bplay{min-width:5.2em;background:#35507a;border-color:#4a6da3;font-weight:600}
143
+ #bplay:hover{background:#40609180}
144
+ #tlwrap{flex:1;min-width:8em;position:relative}
145
+ #timeline{width:100%;height:4.6em;display:block;cursor:pointer;touch-action:none}
146
+ #tllegend{flex:none;font-size:.76em;color:var(--ink2);line-height:1.5;white-space:nowrap}
147
+ #tllegend b{display:inline-block;width:1.2em;text-align:center}
148
+ #turnlabel{flex:none;font-family:Consolas,monospace;font-size:1.25em;min-width:5.5em;text-align:right}
149
+ #turnlabel small{color:var(--muted);font-size:.7em}
150
+ .tip{position:fixed;z-index:20;background:#000d;border:1px solid #445;border-radius:.4em;padding:.35em .6em;font-size:.82em;line-height:1.5;pointer-events:none;display:none;max-width:26em;white-space:pre-line}
151
+ body.narrow footer{flex-wrap:wrap;position:sticky;bottom:0}
152
+ body.narrow #tllegend{display:none}
153
+ body.narrow #tlwrap{flex-basis:100%;order:9}
154
+ body.clean header,body.clean #tllegend{display:none}
155
+
156
+ /* ---- 覆盖层 ---- */
157
+ #endcard{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:#05070acc;border-radius:.4em}
158
+ #endcard.on{display:flex}
159
+ #endin{background:#12161ccc;border:2px solid var(--gold);border-radius:.8em;padding:1.6em 2.4em;text-align:center;max-width:86%;box-shadow:0 0 0 6px #0008}
160
+ #endin .ttl{font-size:.95em;letter-spacing:.4em;color:var(--muted);margin-bottom:.5em}
161
+ #endin .who{font-size:2.8em;font-weight:800;line-height:1.15;word-break:break-all}
162
+ #endin .why{font-size:1.25em;color:var(--ink);margin:.5em 0 .9em}
163
+ #endin table{margin:0 auto .9em;border-collapse:collapse;font-size:1.05em}
164
+ #endin td{padding:.15em .9em;font-family:Consolas,"Microsoft YaHei",monospace}
165
+ #endin td.k{color:var(--muted);font-family:inherit;font-size:.85em}
166
+ #endin td.l{text-align:right}#endin td.r{text-align:left}
167
+ #endin .foot{color:var(--muted);font-size:.8em;margin-top:.7em}
168
+ #drop{position:absolute;inset:0;display:none;align-items:center;justify-content:center;text-align:center;border:3px dashed #3a4452;border-radius:.6em;background:var(--panel);padding:2em;line-height:1.9}
169
+ #drop.on{display:flex}
170
+ #drop b{font-size:1.5em}
171
+ #drop code{background:#0008;padding:.1em .4em;border-radius:.3em;font-family:Consolas,monospace}
172
+ body.dragging #stage{outline:3px dashed var(--gold);outline-offset:4px}
173
+ #help{position:fixed;inset:0;background:#000b;display:none;align-items:center;justify-content:center;z-index:30}
174
+ #help.on{display:flex}
175
+ #helpin{background:var(--panel);border:1px solid var(--line);border-radius:.7em;padding:1.2em 1.6em;max-width:56em;max-height:90vh;overflow:auto;line-height:1.7;font-size:.95em}
176
+ #helpin h3{margin:.6em 0 .2em;font-size:1em;color:var(--gold)}
177
+ #helpin table{border-collapse:collapse}
178
+ #helpin td{padding:.05em 1.2em .05em 0;vertical-align:top}
179
+ #helpin kbd,#helpin code{background:#0009;border:1px solid #3a4452;border-radius:.3em;padding:0 .4em;font-family:Consolas,monospace;font-size:.92em;white-space:nowrap}
180
+ #err{position:fixed;left:0;right:0;top:0;background:#7a1512;color:#fff;padding:.4em 1em;font-size:.9em;z-index:40;display:none;white-space:pre-wrap}
181
+ </style>
182
+ </head>
183
+ <body>
184
+ <div id="err"></div>
185
+ <div id="app">
186
+ <header>
187
+ <h1>坦克大战<small>回放</small></h1>
188
+ <div id="meta">把 <span class="mono">*.frames.json</span> 拖进来,或点"打开文件"</div>
189
+ <button id="bopen" title="打开展开后的逐帧 JSON(O)">打开文件</button>
190
+ <button id="bhelp" title="快捷键和地址栏参数(H)">帮助</button>
191
+ </header>
192
+ <main>
193
+ <aside id="left">
194
+ <div class="panel team s1" id="team1"></div>
195
+ <div class="panel" id="turnbox">
196
+ <div id="turnbig">–</div>
197
+ <div id="phases"><i>开炮</i><i>小步 1</i><i>小步 2</i><i>回合末</i></div>
198
+ <div id="supplyline"></div>
199
+ <canvas id="legend"></canvas>
200
+ </div>
201
+ <div class="panel team s0" id="team0"></div>
202
+ </aside>
203
+ <section id="stage">
204
+ <canvas id="board"></canvas>
205
+ <div id="endcard"><div id="endin"></div></div>
206
+ <div id="drop"><div><b>把展开后的回放拖到这里</b><br>
207
+ 文件由 <code>arena.py expand 回放.json</code> 生成(<code>*.frames.json</code>)。<br>
208
+ 也可以直接用 <code>arena.py html 回放.json</code> 生成一个打开就能看的网页。<br>
209
+ <button id="bopen2" style="margin-top:.8em;font-size:1.05em">打开文件</button></div></div>
210
+ </section>
211
+ <aside id="right">
212
+ <div class="panel" id="evpanel"><h2>本回合事件<span id="evturn"></span></h2><div id="events"></div></div>
213
+ <div class="panel" id="thinkpanel"><h2>每回合思考用量<span id="thinkunit"></span></h2><canvas id="think"></canvas><div id="thinknote"></div></div>
214
+ <div class="panel" id="dbgpanel"><h2>故障与调试输出<span id="dbgturn"></span></h2><div id="debug"></div></div>
215
+ </aside>
216
+ </main>
217
+ <footer>
218
+ <div class="btns">
219
+ <button id="bfirst" title="回到开局(Home)">⏮</button>
220
+ <button id="bpk" title="上一个关键事件([)">◀◆</button>
221
+ <button id="bprev" title="上一回合(←)">◀</button>
222
+ <button id="bplay" title="播放 / 暂停(空格)">▶ 播放</button>
223
+ <button id="bnext" title="下一回合(→)">▶</button>
224
+ <button id="bnk" title="下一个关键事件(])">◆▶</button>
225
+ <button id="blast" title="跳到终局(End)">⏭</button>
226
+ </div>
227
+ <select id="speed" title="播放速度(+ / -)"></select>
228
+ <div id="tllegend"></div>
229
+ <div id="tlwrap"><canvas id="timeline"></canvas></div>
230
+ <div id="turnlabel"></div>
231
+ </footer>
232
+ </div>
233
+ <div class="tip" id="tip"></div>
234
+ <div id="help"><div id="helpin"></div></div>
235
+ <input type="file" id="file" accept=".json,.gz,application/json" hidden>
236
+
237
+ <script>
238
+ "use strict";
239
+ // 数据内嵌的位置:arena.py 把下一行的占位换成展开后的 JSON。
240
+ const EMBED=/*__TANK_DATA__*/null;
241
+
242
+ // ====================================================================
243
+ // 常量与小工具
244
+ // ====================================================================
245
+ const $=id=>document.getElementById(id);
246
+ const DV={U:[0,-1],D:[0,1],L:[-1,0],R:[1,0]},OPP={U:'D',D:'U',L:'R',R:'L'};
247
+ const ANG={U:0,R:Math.PI/2,D:Math.PI,L:-Math.PI/2};
248
+ const ARROW={U:'↑',D:'↓',L:'←',R:'→'};
249
+ const TYPE_CN={fast:'快速',fire:'火力',armor:'装甲'};
250
+ const STYLE_CN={'-1':'按种子轮换',0:'开阔',1:'多砖',2:'多水',3:'多钢'};
251
+ const PAL=[{m:'#d47a1a',l:'#f6b25c',d:'#7a430b',k:'#2a1704'},{m:'#3d8fe8',l:'#9ccbff',d:'#1c4a82',k:'#0d1626'}];
252
+ const GOLD='#ffd24a',BAD='#ff5a4d',WARN='#f5c542';
253
+ const FONT='"Segoe UI","Microsoft YaHei UI","Microsoft YaHei",sans-serif',NUMFONT='Consolas,"Courier New",monospace';
254
+ const SPEEDS=[0.5,1,2,4,8,16];
255
+ const TAIL_MAX=1.3; // 停在某一回合后,回合末的特效再播多久(单位:回合)
256
+ const clamp=(v,a,b)=>v<a?a:v>b?b:v;
257
+ const esc=t=>String(t).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
258
+ const fmtMs=v=>v==null?'–':v>=100?v.toFixed(0):v>=10?v.toFixed(1):v.toFixed(2);
259
+
260
+ // 地址栏参数:?a=1&b=2 和 #a=1&b=2 都认
261
+ const ARGS=(()=>{const o={};for(const part of[location.search.slice(1),location.hash.slice(1)])
262
+ for(const kv of part.split('&')){if(!kv)continue;const i=kv.indexOf('=');o[decodeURIComponent(i<0?kv:kv.slice(0,i))]=i<0?'1':decodeURIComponent(kv.slice(i+1))}return o})();
263
+ const argNum=(k,d)=>k in ARGS&&ARGS[k]!==''&&isFinite(+ARGS[k])?+ARGS[k]:d;
264
+
265
+ window.addEventListener('error',e=>{const el=$('err');el.style.display='block';el.textContent='查看器出错:'+e.message+'('+(e.filename||'').split('/').pop()+':'+e.lineno+')'});
266
+
267
+ // ====================================================================
268
+ // 状态
269
+ // ====================================================================
270
+ let D=null,F=[],N=0; // 数据、帧、最后一帧的下标
271
+ let simCache=[],facing=[],keys=[],keyFrames=[],stderrByTurn=[],faultList=[];
272
+ let cur=0,prog=0,tail=TAIL_MAX; // 当前帧;正在播放第 cur+1 回合的进度(0 = 停在 cur);停下后经过的时间
273
+ let playing=false,stepping=false,speed=2,showEnd=true,showCoords=true,lastT=0,dirty=true;
274
+ let shownTurn=-1,hoverThink=-1,hoverTl=-1;
275
+ let holdTail=false; // 地址栏给了 tail= 时画面定在那一刻,直到有任何操作
276
+
277
+ // ====================================================================
278
+ // 载入
279
+ // ====================================================================
280
+ function load(data){
281
+ if(!data||data.format!=='tank-frames-1'){
282
+ const why=data&&data.format==='tank-replay-1'
283
+ ?'这是原始回放(tank-replay-1),查看器读的是展开后的逐帧 JSON。\n先运行 arena.py expand 回放.json 得到 *.frames.json,或者直接 arena.py html 回放.json 生成网页。'
284
+ :'这个文件不是展开后的回放(需要 format 为 tank-frames-1)。';
285
+ alert(why);return false;
286
+ }
287
+ D=data;F=D.frames;N=F.length-1;simCache=[];cur=0;prog=0;tail=TAIL_MAX;playing=false;stepping=false;shownTurn=-1;showEnd=true;
288
+ // 朝向:规则里坦克没有朝向,这里按"最近一次移动或开炮的方向"画,只为好看
289
+ facing=[F[0].tanks.map(t=>t.seat?'D':'U')];
290
+ for(let k=1;k<=N;k++){const f=facing[k-1].slice(),fr=F[k];
291
+ fr.tanks.forEach((t,i)=>{const was=F[k-1].tanks[i].alive,o=fr.orders?fr.orders[t.seat][i%3]:'S';
292
+ if(was&&o&&o!=='S')f[i]=o[0]==='F'?o[1]:o[o.length-1];
293
+ if(!t.alive||!was)f[i]=t.seat?'D':'U'});
294
+ facing.push(f)}
295
+ // 关键事件:击毁、基地掉血、吃星、故障。lane = 得利的一方(故障记在出故障的一方)
296
+ keys=F.map(()=>[]);
297
+ for(let k=1;k<=N;k++)for(const e of F[k].events){
298
+ if(e.type==='death')keys[k].push({kind:'kill',lane:1-seatOf(e.tank),text:`${tname(e.tank)}被毁`+(e.by>=0?'(压出生点)':'')});
299
+ else if(e.type==='base_hit')keys[k].push({kind:'base',lane:1-e.seat,text:`${D.names[e.seat]} 的基地被打中`});
300
+ else if(e.type==='star')keys[k].push({kind:'star',lane:seatOf(e.tank),text:`${tname(e.tank)}吃星升到 ${e.level} 级`});
301
+ }
302
+ faultList=(D.faults||[]).slice().sort((a,b)=>a.turn-b.turn);
303
+ for(const f of faultList)keys[clamp(f.turn,0,N)].push({kind:'fault',lane:f.seat,text:`${D.names[f.seat]} 故障:${faultKind(f.kind)}`});
304
+ keyFrames=[];keys.forEach((v,k)=>{if(v.length)keyFrames.push(k)});
305
+ stderrByTurn={};
306
+ (D.stderr||[]).forEach((list,seat)=>{for(const[turn,line]of list||[])(stderrByTurn[turn]=stderrByTurn[turn]||[]).push([seat,line])});
307
+ $('drop').classList.remove('on');
308
+ buildStatic();layout();
309
+ return true;
310
+ }
311
+ const seatOf=i=>i<3?0:1;
312
+ const typeOf=i=>['fast','fire','armor'][i%3];
313
+ const tname=i=>`${D.names[seatOf(i)]} 的${TYPE_CN[typeOf(i)]}`;
314
+ const faultKind=k=>({timeout:'超出思考预算',illegal:'输出不合法',exit:'程序崩溃或退出'}[k]||k);
315
+ function reasonText(r){
316
+ const M={base:'对方基地被毁',hp:'到回合上限,比基地血量',kills:'到回合上限,比击毁数',stars:'到回合上限,比吃星数',damage:'到回合上限,比基地伤害',draw:'到回合上限,各项都相同',fault:'对手故障判负'};
317
+ if(r&&r.startsWith('both_')){const x=r.slice(5);return '两个基地同回合被毁,'+(x==='draw'?'各项都相同':'比'+({kills:'击毁数',stars:'吃星数',damage:'基地伤害',hp:'基地血量'}[x]||x))}
318
+ return M[r]||r||'';
319
+ }
320
+ function nextSupply(turn){ // 下一批补给在第几回合末出现(严格大于 turn)
321
+ const P=D.params,a=P.supply_first,per=P.supply_period;
322
+ if(a==null||!per)return null;
323
+ const t=turn<a?a:a+(Math.floor((turn-a)/per)+1)*per;
324
+ return t>(P.max_turns||1e9)?null:t;
325
+ }
326
+ const maxHp=t=>(D.params[t.type+'_hp']||1)+(t.level||0)*(D.params.level_hp||0);
327
+
328
+ // ====================================================================
329
+ // 重建一回合的两个小步
330
+ // 帧里只有回合前后的局面和事件。这里按引擎 step() 的顺序重算一遍,得到每辆车、每发子弹
331
+ // 在每个小步的位置和下场,动画和静态标记都从它出。?check=1 会逐回合核对重算结果和下一帧是否一致。
332
+ // ====================================================================
333
+ function simulate(k){
334
+ if(simCache[k])return simCache[k];
335
+ const A=F[k-1],B=F[k],n=D.size,P=D.params;
336
+ const grid=A.map.map(r=>r.split(''));
337
+ const cell=(x,y)=>x<0||y<0||x>=n||y>=n?'X':grid[y][x];
338
+ const T=A.tanks.map(t=>({id:t.id,seat:t.seat,on:t.alive,x:t.x,y:t.y,pos:[[t.x,t.y]],bump:[null,null],mv:[null,null],fire:null,hitT:[]}));
339
+ const fx=[],walls=[],baseT=[[],[]];
340
+ // 基地限伤(base_hit_cap):每个基地每回合最多扣这么多血,两个小步合计、敌我的子弹合在一起按先后数;
341
+ // 超出的子弹照样消失但不扣血。baseT 只记真正扣了血的那几次命中的时刻。
342
+ const baseHits=[0,0],baseLeft=[A.base_hp[0],A.base_hp[1]],cap=P.base_hit_cap||0;
343
+ const bullets=A.bullets.map(b=>({x:b.x,y:b.y,dir:b.dir,owner:b.owner,steel:b.steel,fresh:false,x0:b.x,y0:b.y,end:0,endS:9,kind:null}));
344
+ for(const e of B.events)if(e.type==='fire'){
345
+ const t=T[e.tank],at=A.tanks[e.tank];t.fire=e.dir;
346
+ bullets.push({x:t.x,y:t.y,dir:e.dir,owner:e.tank,steel:at.level>=2&&P.level2_steel!==0,fresh:true,x0:t.x,y0:t.y,end:0,endS:9,kind:null});
347
+ fx.push({t:0,type:'muzzle',x:t.x,y:t.y,dir:e.dir,seat:at.seat});
348
+ }
349
+ A.tanks.forEach((t,i)=>{
350
+ if(!t.alive||!B.orders)return;
351
+ const o=B.orders[t.seat][i%3]||'S';
352
+ if(o==='S'||o[0]==='F')return;
353
+ T[i].mv[0]=o[0];
354
+ if(o.length>1&&(P[t.type+'_speed']||1)>=2)T[i].mv[1]=o[1];
355
+ });
356
+ const maps=[];
357
+ for(let s=0;s<2;s++){
358
+ const at=(x,y)=>T.find(t=>t.on&&t.x===x&&t.y===y);
359
+ // (a) 坦克移动
360
+ const want=[];
361
+ for(const t of T){const d=t.mv[s];if(!t.on||!d)continue;
362
+ const tx=t.x+DV[d][0],ty=t.y+DV[d][1];
363
+ if(cell(tx,ty)==='.'&&!at(tx,ty))want.push({t,tx,ty});else t.bump[s]=d}
364
+ const moved=[];
365
+ for(const w of want){if(want.some(o=>o!==w&&o.tx===w.tx&&o.ty===w.ty))w.t.bump[s]=w.t.mv[s];else moved.push(w)}
366
+ for(const w of moved){w.fx=w.t.x;w.fy=w.t.y;w.t.x=w.tx;w.t.y=w.ty}
367
+ for(const t of T)t.pos.push([t.x,t.y]);
368
+ // (b) 子弹前进
369
+ const live=bullets.filter(b=>!b.end);
370
+ for(const b of live){b.px=b.x;b.py=b.y;b.x+=DV[b.dir][0];b.y+=DV[b.dir][1]}
371
+ // (c) 判定。back = 子弹在这一小步里飞到一半就没了的那部分(画面上撞在墙面、车身上,而不是格子中心)
372
+ const kill=(b,kind,back)=>{if(b.end)return (b.endS)/2;b.end=s+1;b.kind=kind;b.endS=s+1-back;return b.endS/2};
373
+ if(P.bullet_cancel!==0)for(let a=0;a<live.length;a++)for(let c=a+1;c<live.length;c++){
374
+ const ba=live[a],bb=live[c];
375
+ if(bb.dir===OPP[ba.dir]&&ba.px===bb.x&&ba.py===bb.y){
376
+ kill(ba,'cross',.5);const t=kill(bb,'cross',.5);
377
+ fx.push({t,type:'cancel',x:(ba.x+ba.px)/2,y:(ba.y+ba.py)/2})}
378
+ }
379
+ for(const w of moved)for(const b of live){
380
+ if(!b.end&&b.x===w.fx&&b.y===w.fy&&b.px===w.t.x&&b.py===w.t.y){
381
+ const t=kill(b,'swap',.5);w.t.hitT.push(t);fx.push({t,type:'hit',x:(b.x+b.px)/2,y:(b.y+b.py)/2,tank:w.t.id})}
382
+ }
383
+ const brk=[],stl=[];
384
+ for(const b of live){
385
+ if(b.end)continue;
386
+ const c=cell(b.x,b.y);
387
+ if(c==='.'){const v=at(b.x,b.y);if(v){const t=kill(b,'hit',.3);v.hitT.push(t);fx.push({t,type:'hit',x:b.x,y:b.y,tank:v.id})}}
388
+ else if(c==='~'){}
389
+ else if(c==='#'){const t=kill(b,'brick',.45);brk.push([b.x,b.y,t])}
390
+ else if(c==='@'){const t=kill(b,'steel',.45);if(b.steel)stl.push([b.x,b.y,t]);else fx.push({t,type:'ding',x:b.x,y:b.y,dir:b.dir})}
391
+ else if(c==='X'){const t=kill(b,'out',.5);fx.push({t,type:'out',x:b.x,y:b.y,dir:b.dir})}
392
+ else{const seat=+c,t=kill(b,'base',.4);baseHits[seat]++;
393
+ const dmg=baseLeft[seat]>0&&!(cap&&baseHits[seat]>cap);
394
+ if(dmg){baseLeft[seat]--;baseT[seat].push(t)}
395
+ fx.push({t,type:'basehit',x:b.x,y:b.y,seat,dmg})}
396
+ }
397
+ for(const[list,ch,kind]of[[brk,'#','brick'],[stl,'@','steel']])for(const[x,y,t]of list)if(grid[y][x]===ch){
398
+ grid[y][x]='.';walls.push({x,y,kind,sub:s+1});fx.push({t,type:'debris',x,y,kind})}
399
+ if(P.bullet_cancel!==0){
400
+ const left=live.filter(b=>!b.end);
401
+ for(let a=1;a<left.length;a++)for(let j=0;j<a;j++)if(left[j].x===left[a].x&&left[j].y===left[a].y){
402
+ kill(left[a],'same',0);kill(left[j],'same',0);fx.push({t:(s+1)/2,type:'cancel',x:left[a].x,y:left[a].y});break}
403
+ }
404
+ maps.push(grid.map(r=>r.join('')));
405
+ }
406
+ // 回合末的事:都记在 t = 1(两个小步走完的那一刻)
407
+ for(const e of B.events){
408
+ if(e.type==='death')fx.push({t:1,type:'death',x:e.x,y:e.y,tank:e.tank,by:e.by});
409
+ else if(e.type==='respawn')fx.push({t:1,type:'respawn',x:e.x,y:e.y,tank:e.tank});
410
+ else if(e.type==='star')fx.push({t:1,type:'star',x:e.x,y:e.y,tank:e.tank,level:e.level});
411
+ else if(e.type==='supply')fx.push({t:1,type:'supply',x:e.x,y:e.y});
412
+ else if(e.type==='bullet_cleared')fx.push({t:1,type:'cleared',x:e.x,y:e.y});
413
+ }
414
+ return simCache[k]={T,bullets,walls,maps,fx,baseT};
415
+ }
416
+
417
+ // 自查:重算结果和下一帧对不上的地方。返回问题列表(空 = 全部一致)。
418
+ function selfCheck(){
419
+ const bad=[];
420
+ for(let k=1;k<=N;k++){
421
+ const A=F[k-1],B=F[k],S=simulate(k),ev=B.events,note=m=>{if(bad.length<40)bad.push(`回合 ${k}:${m}`)};
422
+ const touched=new Set(ev.filter(e=>['death','respawn','star'].includes(e.type)).map(e=>e.tank));
423
+ S.T.forEach((t,i)=>{
424
+ if(!t.on||touched.has(i)||!B.tanks[i].alive)return;
425
+ if(t.x!==B.tanks[i].x||t.y!==B.tanks[i].y)note(`${i} 号车位置 重算 (${t.x},${t.y}) 帧里 (${B.tanks[i].x},${B.tanks[i].y})`);
426
+ if(A.tanks[i].hp-t.hitT.length!==B.tanks[i].hp)note(`${i} 号车血量 重算 ${A.tanks[i].hp-t.hitT.length} 帧里 ${B.tanks[i].hp}`);
427
+ });
428
+ const blk=ev.filter(e=>e.type==='blocked').map(e=>e.tank+':'+e.sub).sort().join(),mine=[];
429
+ S.T.forEach((t,i)=>t.bump.forEach((d,s)=>{if(d)mine.push(i+':'+(s+1))}));
430
+ if(blk!==mine.sort().join())note(`没走成 重算 [${mine}] 事件 [${blk}]`);
431
+ const cleared=ev.filter(e=>e.type==='bullet_cleared').map(e=>e.x+','+e.y);
432
+ const sig=b=>[b.x,b.y,b.dir,b.owner,b.steel?1:0].join();
433
+ const a=S.bullets.filter(b=>!b.end&&!cleared.includes(b.x+','+b.y)).map(sig).sort().join(';'),b=B.bullets.map(sig).sort().join(';');
434
+ if(a!==b)note(`子弹 重算 [${a}] 帧里 [${b}]`);
435
+ if(S.maps[1].join()!==B.map.join())note('地形对不上');
436
+ const w1=S.walls.map(w=>[w.x,w.y,w.kind,w.sub].join()).sort().join(';'),w2=ev.filter(e=>e.type==='wall').map(w=>[w.x,w.y,w.kind,w.sub].join()).sort().join(';');
437
+ if(w1!==w2)note(`墙体事件 重算 [${w1}] 事件 [${w2}]`);
438
+ for(const[type,kinds]of[['hit',['hit','swap']],['base_hit',['base']]]){
439
+ const n1=ev.filter(e=>e.type===type).length,n2=S.bullets.filter(b=>kinds.includes(b.kind)).length;
440
+ if(n1!==n2)note(`${type} 次数 重算 ${n2} 事件 ${n1}`)}
441
+ const c1=ev.filter(e=>e.type==='cancel').length,c2=S.fx.filter(e=>e.type==='cancel').length;
442
+ if(c1!==c2)note(`对消次数 重算 ${c2} 事件 ${c1}`);
443
+ if(!D.params.base_regen&&!D.params.base_armor)for(const s of[0,1])
444
+ if(A.base_hp[s]-S.baseT[s].length!==B.base_hp[s])note(`${s} 号方基地血量 重算 ${A.base_hp[s]-S.baseT[s].length} 帧里 ${B.base_hp[s]}`);
445
+ }
446
+ return bad;
447
+ }
448
+
449
+ // ====================================================================
450
+ // 画图:地形、坦克、子弹
451
+ // ====================================================================
452
+ const cv=$('board'),g=cv.getContext('2d');
453
+ let CS=40,OX=0,OY=0; // 格子边长、棋盘左上角(画布像素)
454
+ let terrainKey='',terrainImg=null;
455
+ const px=x=>OX+x*CS,py=y=>OY+y*CS,cxOf=x=>OX+(x+.5)*CS,cyOf=y=>OY+(y+.5)*CS;
456
+
457
+ function drawBrick(c,x,y,s){
458
+ c.fillStyle='#2a1509';c.fillRect(x,y,s,s);
459
+ const rows=4,h=s/rows,m=Math.max(1,s*.035);
460
+ for(let r=0;r<rows;r++){const off=r%2?-s/4:0;
461
+ for(let i=0;i<3;i++){const bx=x+off+i*s/2,l=Math.max(x,bx)+m/2,rr=Math.min(x+s,bx+s/2)-m/2;if(rr<=l)continue;
462
+ c.fillStyle='#a4502a';c.fillRect(l,y+r*h+m/2,rr-l,h-m);
463
+ c.fillStyle='#c9713f';c.fillRect(l,y+r*h+m/2,rr-l,Math.max(1,h*.22))}}
464
+ }
465
+ function drawSteel(c,x,y,s){
466
+ c.fillStyle='#3d444d';c.fillRect(x,y,s,s);
467
+ const h=s/2,m=Math.max(1,s*.04);
468
+ for(let i=0;i<2;i++)for(let j=0;j<2;j++){const bx=x+i*h+m/2,by=y+j*h+m/2,w=h-m;
469
+ c.fillStyle='#e3e8ee';c.fillRect(bx,by,w,w);
470
+ c.fillStyle='#727c88';c.beginPath();c.moveTo(bx+w,by);c.lineTo(bx+w,by+w);c.lineTo(bx,by+w);c.closePath();c.fill();
471
+ c.fillStyle='#aab3be';c.fillRect(bx+w*.22,by+w*.22,w*.56,w*.56)}
472
+ }
473
+ function drawWater(c,x,y,s){
474
+ c.fillStyle='#143a6b';c.fillRect(x,y,s,s);
475
+ c.strokeStyle='#4b8fd6';c.lineWidth=Math.max(1,s*.05);c.lineCap='round';
476
+ for(let r=0;r<3;r++){const yy=y+s*(.22+r*.28);c.beginPath();
477
+ for(let i=0;i<=8;i++){const xx=x+s*(.1+i*.1),dy=Math.sin(i*1.57+r*1.3)*s*.045;i?c.lineTo(xx,yy+dy):c.moveTo(xx,yy+dy)}c.stroke()}
478
+ }
479
+ function drawEmpty(c,x,y,s){c.fillStyle='#0f1216';c.fillRect(x,y,s,s);c.strokeStyle='#1c2129';c.lineWidth=1;c.strokeRect(x+.5,y+.5,s-1,s-1)}
480
+ function terrain(map){
481
+ const key=CS+'|'+cv.width+'|'+map.join('');
482
+ if(key===terrainKey)return terrainImg;
483
+ const n=D.size,im=terrainImg||document.createElement('canvas');im.width=n*CS;im.height=n*CS;
484
+ const c=im.getContext('2d');
485
+ for(let y=0;y<n;y++)for(let x=0;x<n;x++){const ch=map[y][x],X=x*CS,Y=y*CS;
486
+ drawEmpty(c,X,Y,CS);
487
+ if(ch==='#')drawBrick(c,X,Y,CS);else if(ch==='@')drawSteel(c,X,Y,CS);else if(ch==='~')drawWater(c,X,Y,CS)}
488
+ terrainKey=key;terrainImg=im;return im;
489
+ }
490
+ function starPath(c,x,y,r,rot=0){c.beginPath();for(let k=0;k<10;k++){const a=Math.PI/5*k-Math.PI/2+rot,rr=k%2?r*.45:r;c.lineTo(x+rr*Math.cos(a),y+rr*Math.sin(a))}c.closePath()}
491
+ function drawStar(c,x,y,r,alpha=1){c.save();c.globalAlpha*=alpha;starPath(c,x,y,r);c.fillStyle=GOLD;c.fill();c.lineWidth=Math.max(1,r*.14);c.strokeStyle='#7a5a00';c.stroke();
492
+ starPath(c,x,y,r*.45);c.fillStyle='#fff6c8';c.fill();c.restore()}
493
+ function drawSupply(c,x,y,s){ // 补给点:地上的虚线圈加四个刻度
494
+ c.save();c.strokeStyle='#b89a3a';c.lineWidth=Math.max(1,s*.04);c.setLineDash([s*.09,s*.07]);c.beginPath();c.arc(x,y,s*.36,0,7);c.stroke();c.setLineDash([]);
495
+ for(let k=0;k<4;k++){const a=k*Math.PI/2+Math.PI/4;c.beginPath();c.moveTo(x+Math.cos(a)*s*.4,y+Math.sin(a)*s*.4);c.lineTo(x+Math.cos(a)*s*.48,y+Math.sin(a)*s*.48);c.stroke()}
496
+ c.restore()}
497
+ function drawSpawn(c,x,y,s,seat){ // 出生点:四个角标
498
+ c.save();c.strokeStyle=PAL[seat].m;c.globalAlpha*=.85;c.lineWidth=Math.max(1.5,s*.055);const a=s*.42,b=s*.2;
499
+ for(const[sx,sy]of[[-1,-1],[1,-1],[1,1],[-1,1]]){c.beginPath();c.moveTo(x+sx*a,y+sy*(a-b));c.lineTo(x+sx*a,y+sy*a);c.lineTo(x+sx*(a-b),y+sy*a);c.stroke()}
500
+ c.restore()}
501
+ function drawBase(c,x,y,s,seat,hp,maxhp,flash){
502
+ const p=PAL[seat],X=x-s/2,Y=y-s/2;
503
+ c.save();
504
+ c.fillStyle=hp>0?'#0b0d10':'#1a1414';c.fillRect(X,Y,s,s);
505
+ if(hp>0){
506
+ // 盾形徽记:0 号方实心,1 号方斜纹(不只靠颜色区分)
507
+ const sh=new Path2D();sh.moveTo(x-s*.34,y-s*.40);sh.lineTo(x+s*.34,y-s*.40);sh.lineTo(x+s*.34,y-s*.02);sh.lineTo(x,y+s*.26);sh.lineTo(x-s*.34,y-s*.02);sh.closePath();
508
+ c.fillStyle=flash?'#fff':p.m;c.fill(sh);
509
+ if(seat===1&&!flash){c.save();c.clip(sh);c.strokeStyle=p.k;c.lineWidth=s*.07;for(let i=-4;i<5;i++){c.beginPath();c.moveTo(x+i*s*.17-s*.4,y+s*.4);c.lineTo(x+i*s*.17+s*.4,y-s*.4);c.stroke()}c.restore()}
510
+ c.lineWidth=Math.max(1.5,s*.05);c.strokeStyle=p.l;c.stroke(sh);
511
+ starPath(c,x,y-s*.12,s*.17);c.fillStyle=flash?p.m:'#fff';c.fill();
512
+ }else{
513
+ c.strokeStyle='#8a8f96';c.lineWidth=s*.09;c.lineCap='round';
514
+ c.beginPath();c.moveTo(x-s*.28,y-s*.32);c.lineTo(x+s*.28,y+s*.14);c.moveTo(x+s*.28,y-s*.32);c.lineTo(x-s*.28,y+s*.14);c.stroke();
515
+ }
516
+ // 血条和数字
517
+ const bw=s*.84,bh=s*.13,bx=x-bw/2,by=y+s*.33;
518
+ c.fillStyle='#000';c.fillRect(bx-1,by-1,bw+2,bh+2);c.fillStyle='#39414d';c.fillRect(bx,by,bw,bh);
519
+ c.fillStyle=hp>0?p.l:BAD;c.fillRect(bx,by,bw*clamp(hp/maxhp,0,1),bh);
520
+ c.restore();
521
+ }
522
+
523
+ // 坦克。三种车型外形不同:快速型尖头窄车身加三角炮塔,火力型圆炮塔双炮管,装甲型宽车身方炮塔粗炮管。
524
+ // 两方除颜色外:0 号方炮塔是浅色实心的,1 号方炮塔是深色空心的、车身有斜纹。
525
+ function drawTank(c,x,y,s,type,seat,dir,o){
526
+ o=o||{};const p=PAL[seat];
527
+ c.save();c.translate(x,y);c.rotate(ANG[dir]);c.scale(s,s);c.globalAlpha*=o.alpha==null?1:o.alpha;
528
+ const fl=o.flash,dead=o.dead,ghost=o.ghost;
529
+ const col=v=>fl?'#fff':dead?'#4a4d52':v;
530
+ const W=type==='armor'?.46:type==='fast'?.35:.41,tw=type==='armor'?.15:.12,L=type==='fast'?.37:type==='armor'?.42:.39;
531
+ c.lineJoin='round';
532
+ if(ghost){c.strokeStyle=p.l;c.lineWidth=.035;c.setLineDash([.07,.05])}
533
+ const R=(a,b,w,h,fill)=>{if(ghost){c.strokeRect(a,b,w,h)}else{c.fillStyle=fill;c.fillRect(a,b,w,h)}};
534
+ // 履带
535
+ for(const sx of[-1,1]){const tx=sx>0?W-tw:-W;R(tx,-L,tw,2*L,col(p.d));
536
+ if(!ghost){c.fillStyle=fl?'#ddd':'#00000088';const ph=((o.tread||0)%1+1)%1*.1;for(let yy=-L+ph;yy<L-.02;yy+=.1)c.fillRect(tx,yy,tw,.035)}}
537
+ // 车身
538
+ const hull=new Path2D();
539
+ if(type==='fast'){hull.moveTo(-.21,.33);hull.lineTo(-.21,-.12);hull.lineTo(0,-.37);hull.lineTo(.21,-.12);hull.lineTo(.21,.33)}
540
+ else if(type==='fire'){hull.rect(-.27,-.33,.54,.67)}
541
+ else{hull.rect(-.33,-.40,.66,.80)}
542
+ hull.closePath();
543
+ if(ghost)c.stroke(hull);else{c.fillStyle=col(p.m);c.fill(hull);
544
+ if(seat===1&&!fl&&!dead){c.save();c.clip(hull);c.strokeStyle=p.d;c.lineWidth=.06;for(let i=-3;i<4;i++){c.beginPath();c.moveTo(i*.2-.4,.4);c.lineTo(i*.2+.4,-.4);c.stroke()}c.restore()}
545
+ c.lineWidth=.03;c.strokeStyle=fl?'#fff':dead?'#2a2c30':p.k;c.stroke(hull);
546
+ if(type==='armor'){c.fillStyle=col(p.l);c.fillRect(-.33,-.40,.66,.07);c.fillRect(-.33,.33,.66,.07)}}
547
+ // 炮管
548
+ const bc=col(seat?p.l:'#fff3dc');
549
+ if(type==='fast')R(-.03,-.49,.06,.42,bc);
550
+ else if(type==='fire'){R(-.125,-.49,.07,.5,bc);R(.055,-.49,.07,.5,bc)}
551
+ else R(-.06,-.49,.12,.42,bc);
552
+ // 炮塔
553
+ c.beginPath();
554
+ if(type==='fast'){c.moveTo(0,-.14);c.lineTo(.13,.13);c.lineTo(-.13,.13);c.closePath()}
555
+ else if(type==='fire')c.arc(0,.04,.17,0,7);
556
+ else c.rect(-.2,-.15,.4,.4);
557
+ if(ghost)c.stroke();else{c.fillStyle=fl?'#fff':dead?'#333':seat?p.k:p.l;c.fill();c.lineWidth=seat?.05:.03;c.strokeStyle=fl?'#fff':dead?'#222':seat?p.l:p.k;c.stroke()}
558
+ c.restore();
559
+ }
560
+ // 坦克头顶和脚下的小标记:血量格、等级星、子弹格(实心 = 还能打,空心 = 在飞;红色 = 装填中)
561
+ function drawHud(c,x,y,s,h){
562
+ c.save();
563
+ const n=Math.max(h.maxhp,1),pw=Math.min(s*.15,s*.8/n),ph=s*.085,gap=s*.025,tw=n*pw+(n-1)*gap,top=y-s*.5+s*.015;
564
+ c.fillStyle='#000c';c.fillRect(x-tw/2-2,top-1,tw+4,ph+3);
565
+ for(let i=0;i<n;i++){const bx=x-tw/2+i*(pw+gap);
566
+ if(i<h.hp){c.fillStyle='#62d08a';c.fillRect(bx,top,pw,ph)}else{c.strokeStyle='#62d08a';c.lineWidth=1;c.strokeRect(bx+.5,top+.5,pw-1,ph-1)}}
567
+ if(h.level>0){const r=s*.1;c.fillStyle='#000c';c.fillRect(x-s*.5,y+s*.5-r*2.2,h.level*r*2.1+2,r*2.2);
568
+ for(let i=0;i<h.level;i++){starPath(c,x-s*.5+r*1.05+i*r*2.1,y+s*.5-r*1.1,r);c.fillStyle=GOLD;c.fill()}}
569
+ if(h.cap>0){const r=s*.05,w=h.cap*r*2.6;c.fillStyle='#000c';c.fillRect(x+s*.5-w-2,y+s*.5-r*2.8,w+2,r*2.8);
570
+ for(let i=0;i<h.cap;i++){const bx=x+s*.5-r*1.4-i*r*2.6,by=y+s*.5-r*1.4;c.beginPath();c.arc(bx,by,r,0,7);
571
+ const ready=i<h.cap-h.out;
572
+ if(h.reload){c.strokeStyle=BAD;c.lineWidth=Math.max(1,s*.025);c.stroke();c.beginPath();c.moveTo(bx-r,by+r);c.lineTo(bx+r,by-r);c.stroke()}
573
+ else if(ready){c.fillStyle='#fff';c.fill()}else{c.strokeStyle='#fff';c.lineWidth=Math.max(1,s*.022);c.stroke()}}}
574
+ c.restore();
575
+ }
576
+ // 子弹:0 号方是圆头弹,1 号方是菱形弹;能破钢的子弹外面多一圈白环
577
+ function drawBullet(c,x,y,s,dir,owner,steel,trail,alpha){
578
+ const seat=seatOf(owner),p=PAL[seat],d=DV[dir];
579
+ c.save();c.globalAlpha*=alpha==null?1:alpha;
580
+ if(trail>0){const gr=c.createLinearGradient(x,y,x-d[0]*trail*s,y-d[1]*trail*s);gr.addColorStop(0,p.l+'cc');gr.addColorStop(1,p.l+'00');
581
+ c.strokeStyle=gr;c.lineWidth=s*.13;c.lineCap='butt';c.beginPath();c.moveTo(x,y);c.lineTo(x-d[0]*trail*s,y-d[1]*trail*s);c.stroke()}
582
+ c.translate(x,y);c.rotate(ANG[dir]);c.scale(s,s);
583
+ if(steel){c.strokeStyle='#fff';c.lineWidth=.04;c.beginPath();c.arc(0,0,.26,0,7);c.stroke()}
584
+ c.beginPath();
585
+ if(seat===0){c.moveTo(-.095,.17);c.lineTo(-.095,-.08);c.arc(0,-.08,.095,Math.PI,0);c.lineTo(.095,.17)}
586
+ else{c.moveTo(0,-.23);c.lineTo(.12,0);c.lineTo(0,.2);c.lineTo(-.12,0)}
587
+ c.closePath();c.fillStyle='#fff';c.fill();c.lineWidth=.055;c.strokeStyle=p.m;c.stroke();
588
+ c.restore();
589
+ }
590
+ function label(c,text,x,y,size,color,alpha,stroke){
591
+ c.save();c.globalAlpha*=alpha==null?1:alpha;c.font=`700 ${size}px ${FONT}`;c.textAlign='center';c.textBaseline='middle';
592
+ c.lineWidth=Math.max(2,size*.22);c.strokeStyle=stroke||'#000d';c.lineJoin='round';c.strokeText(text,x,y);c.fillStyle=color;c.fillText(text,x,y);c.restore();
593
+ }
594
+ const rnd=(i,j)=>{const v=Math.sin(i*127.1+j*311.7)*43758.5453;return v-Math.floor(v)}; // 固定的"随机",同一处每次画出来一样
595
+
596
+ // ====================================================================
597
+ // 特效:每个特效是 (类型, 位置, 开始时刻),画成它的"年龄"的函数,所以拖到哪里都能直接画出来
598
+ // ====================================================================
599
+ function drawFx(e,age,S){
600
+ if(age<0)return;
601
+ const s=CS,x=cxOf(e.x),y=cyOf(e.y);
602
+ g.save();
603
+ switch(e.type){
604
+ case 'muzzle':{if(age>.22)break;const u=age/.22,d=DV[e.dir],mx=x+d[0]*s*.55,my=y+d[1]*s*.55;
605
+ g.translate(mx,my);g.rotate(ANG[e.dir]);g.globalAlpha=1-u;
606
+ g.fillStyle='#fff7c0';g.beginPath();const r=s*(.18+.2*u);
607
+ for(let k=0;k<8;k++){const a=k*Math.PI/4,rr=k%2?r*.45:r*(k===0?1.7:1);g.lineTo(Math.sin(a)*rr,-Math.cos(a)*rr)}g.closePath();g.fill();
608
+ g.strokeStyle='#ff9d2e';g.lineWidth=s*.04;g.stroke();break}
609
+ case 'hit':{if(age<.4){const u=age/.4;g.globalAlpha=1-u;g.strokeStyle='#fff';g.lineWidth=s*.07;g.lineCap='round';
610
+ for(let k=0;k<8;k++){const a=k*Math.PI/4+.3;g.beginPath();g.moveTo(x+Math.cos(a)*s*(.12+.3*u),y+Math.sin(a)*s*(.12+.3*u));g.lineTo(x+Math.cos(a)*s*(.26+.42*u),y+Math.sin(a)*s*(.26+.42*u));g.stroke()}
611
+ g.strokeStyle=BAD;g.lineWidth=s*.06;g.beginPath();g.arc(x,y,s*(.2+.4*u),0,7);g.stroke()}
612
+ if(age<.75){const u=age/.75;label(g,'-1',x+s*.32,y-s*(.3+.5*u),s*.42,BAD,1-u*u)}break}
613
+ case 'ding':{if(age>.28)break;const u=age/.28,d=DV[e.dir],ix=x-d[0]*s*.5,iy=y-d[1]*s*.5;g.globalAlpha=1-u;g.strokeStyle='#e8eef5';g.lineWidth=s*.045;g.lineCap='round';
614
+ for(let k=-2;k<=2;k++){const a=Math.atan2(-d[1],-d[0])+k*.5;g.beginPath();g.moveTo(ix+Math.cos(a)*s*.08,iy+Math.sin(a)*s*.08);g.lineTo(ix+Math.cos(a)*s*(.14+.3*u),iy+Math.sin(a)*s*(.14+.3*u));g.stroke()}break}
615
+ case 'out':{if(age>.2)break;const d=DV[e.dir];g.globalAlpha=1-age/.2;g.fillStyle='#fff';g.beginPath();g.arc(x-d[0]*s*.5,y-d[1]*s*.5,s*.08,0,7);g.fill();break}
616
+ case 'basehit':{if(age<.5){const u=age/.5;g.globalAlpha=1-u;g.strokeStyle=BAD;g.lineWidth=s*.1;g.strokeRect(x-s*(.5+.25*u),y-s*(.5+.25*u),s*(1+.5*u),s*(1+.5*u));
617
+ g.strokeStyle='#fff';g.lineWidth=s*.05;g.strokeRect(x-s*(.5+.1*u),y-s*(.5+.1*u),s*(1+.2*u),s*(1+.2*u))}
618
+ if(e.dmg!==false&&age<.9){const u=age/.9,up=e.y<D.size/2?1:-1;label(g,'基地 -1',x,y+up*s*(.95+.35*u),s*.4,BAD,1-u*u)}break}
619
+ case 'debris':{if(age>.5)break;const u=age/.5,cols=e.kind==='brick'?['#c9713f','#a4502a','#7d3a1c']:['#e3e8ee','#aab3be','#727c88'];
620
+ if(u<.3){g.globalAlpha=1-u/.3;g.fillStyle='#fff';g.fillRect(x-s/2,y-s/2,s,s)}
621
+ g.globalAlpha=1-u*u;
622
+ for(let k=0;k<10;k++){const a=rnd(e.x*17+k,e.y*31)*6.283,r=s*(.12+(.35+.5*rnd(k,e.x+e.y))*u),sz=s*(.1+.09*rnd(k,7));
623
+ g.fillStyle=cols[k%3];g.fillRect(x+Math.cos(a)*r-sz/2,y+Math.sin(a)*r-sz/2+s*.25*u*u,sz,sz)}break}
624
+ case 'cancel':{if(age>.4)break;const u=age/.4;g.globalAlpha=1-u;g.strokeStyle='#fff';g.lineWidth=s*.07;g.lineCap='round';const r=s*(.1+.18*u);
625
+ g.beginPath();g.moveTo(x-r,y-r);g.lineTo(x+r,y+r);g.moveTo(x+r,y-r);g.lineTo(x-r,y+r);g.stroke();
626
+ g.strokeStyle='#bfe9ff';g.lineWidth=s*.04;g.beginPath();g.arc(x,y,s*(.12+.38*u),0,7);g.stroke();break}
627
+ case 'death':{const dur=.95;if(age<dur){const u=age/dur;
628
+ for(const[r0,c0,d0]of[[.62,'#ff7a1a',0],[.46,'#ffd24a',.06],[.28,'#fff',.12]]){const v=clamp((u-d0)/.55,0,1);if(v<=0||v>=1)continue;
629
+ g.globalAlpha=1-v*v;g.fillStyle=c0;g.beginPath();g.arc(x,y,s*r0*(.35+.9*v),0,7);g.fill()}
630
+ g.globalAlpha=clamp(1.2-u*1.3,0,1);g.strokeStyle='#ffd24a';g.lineWidth=s*.07;g.lineCap='round';
631
+ for(let k=0;k<10;k++){const a=k*.628+.2,r1=s*(.3+.75*u),r2=s*(.45+1.0*u);g.beginPath();g.moveTo(x+Math.cos(a)*r1,y+Math.sin(a)*r1);g.lineTo(x+Math.cos(a)*r2,y+Math.sin(a)*r2);g.stroke()}
632
+ g.globalAlpha=clamp((u-.25)/.3,0,1)*(1-u)*.9;g.fillStyle='#555';for(let k=0;k<5;k++){const a=k*1.257;g.beginPath();g.arc(x+Math.cos(a)*s*.3*u,y+Math.sin(a)*s*.3*u-s*.3*u,s*.2,0,7);g.fill()}
633
+ g.globalAlpha=1;label(g,e.by>=0?'压出生点 · 炸毁':'击毁',x,y-s*(.62+.3*u),s*.38,'#fff',clamp(2.4-u*2.4,0,1),PAL[1-seatOf(e.tank)].d)}
634
+ break}
635
+ case 'respawn':{if(age>.7)break;const u=age/.7,p=PAL[seatOf(e.tank)];g.globalAlpha=1-u;g.strokeStyle=p.l;g.lineWidth=s*.06;
636
+ for(const k of[0,.33,.66]){const v=(u+k)%1;g.beginPath();g.arc(x,y,s*(.95-.6*v),0,7);g.stroke()}
637
+ label(g,'复活',x,y-s*.7,s*.36,p.l,1-u);break}
638
+ case 'star':{if(age>.9)break;const u=age/.9;g.globalAlpha=1-u;g.strokeStyle=GOLD;g.lineWidth=s*.08;g.beginPath();g.arc(x,y,s*(.3+.6*u),0,7);g.stroke();
639
+ for(let k=0;k<5;k++){const a=k*1.257-1.57+u;drawStar(g,x+Math.cos(a)*s*(.35+.6*u),y+Math.sin(a)*s*(.35+.6*u),s*.11,1-u)}
640
+ g.globalAlpha=1;label(g,'升级 Lv'+e.level+(e.level>=2&&D.params.level2_steel!==0?' · 破钢':''),x,y-s*(.7+.35*u),s*.4,GOLD,clamp(2.2-u*2.2,0,1));break}
641
+ case 'supply':{if(age>.6)break;const u=age/.6;g.globalAlpha=1-u;g.strokeStyle=GOLD;g.lineWidth=s*.06;g.beginPath();g.arc(x,y,s*(.9-.55*u),0,7);g.stroke();break}
642
+ case 'cleared':{if(age>.3)break;g.globalAlpha=1-age/.3;g.fillStyle='#ccc';g.beginPath();g.arc(x,y,s*.2,0,7);g.fill();break}
643
+ }
644
+ g.restore();
645
+ }
646
+ // 停在某一回合时留在画面上的标记:这一回合子弹飞过的路线、打掉的墙、被毁的位置、没走成的方向……
647
+ function drawMarks(k,alpha){
648
+ if(k<1||alpha<=0)return;
649
+ const S=simulate(k),s=CS;
650
+ g.save();g.globalAlpha=alpha;
651
+ for(const b of S.bullets){const d=DV[b.dir],len=Math.min(b.endS,2),p=PAL[seatOf(b.owner)];
652
+ const x0=cxOf(b.x0),y0=cyOf(b.y0),x1=x0+d[0]*len*s,y1=y0+d[1]*len*s;
653
+ g.strokeStyle=p.l;g.globalAlpha=alpha*.38;g.lineWidth=s*.07;g.setLineDash([s*.12,s*.1]);g.beginPath();g.moveTo(x0,y0);g.lineTo(x1,y1);g.stroke();g.setLineDash([]);
654
+ if(b.end){g.globalAlpha=alpha*.8;g.fillStyle=p.l;g.beginPath();g.arc(x1,y1,s*.06,0,7);g.fill()}}
655
+ g.globalAlpha=alpha;
656
+ for(const w of S.walls){const x=px(w.x),y=py(w.y);g.strokeStyle=w.kind==='brick'?'#c9713f':'#e3e8ee';g.lineWidth=Math.max(1.5,s*.045);g.setLineDash([s*.14,s*.1]);
657
+ g.strokeRect(x+s*.08,y+s*.08,s*.84,s*.84);g.setLineDash([]);
658
+ g.fillStyle=w.kind==='brick'?'#a4502a':'#aab3be';for(let i=0;i<5;i++)g.fillRect(x+s*(.2+.6*rnd(i,w.x)),y+s*(.2+.6*rnd(w.y,i)),s*.08,s*.08)}
659
+ for(const e of S.fx){const x=cxOf(e.x),y=cyOf(e.y);
660
+ if(e.type==='death'){const p=PAL[seatOf(e.tank)];
661
+ g.fillStyle='#000a';g.beginPath();g.arc(x,y,s*.4,0,7);g.fill();
662
+ g.strokeStyle=p.m;g.lineWidth=s*.11;g.lineCap='round';g.beginPath();g.moveTo(x-s*.24,y-s*.24);g.lineTo(x+s*.24,y+s*.24);g.moveTo(x+s*.24,y-s*.24);g.lineTo(x-s*.24,y+s*.24);g.stroke();
663
+ g.strokeStyle='#fff';g.lineWidth=s*.035;g.beginPath();g.moveTo(x-s*.24,y-s*.24);g.lineTo(x+s*.24,y+s*.24);g.moveTo(x+s*.24,y-s*.24);g.lineTo(x-s*.24,y+s*.24);g.stroke()}
664
+ else if(e.type==='cancel'){g.strokeStyle='#fff';g.lineWidth=s*.05;g.lineCap='round';const r=s*.11;g.beginPath();g.moveTo(x-r,y-r);g.lineTo(x+r,y+r);g.moveTo(x+r,y-r);g.lineTo(x-r,y+r);g.stroke();
665
+ g.beginPath();g.arc(x,y,s*.2,0,7);g.lineWidth=s*.03;g.stroke()}
666
+ else if(e.type==='ding'){const d=DV[e.dir];g.fillStyle='#e8eef5';g.beginPath();g.arc(x-d[0]*s*.5,y-d[1]*s*.5,s*.07,0,7);g.fill()}
667
+ }
668
+ // 没走成:朝想去的方向画一道红杠
669
+ S.T.forEach(t=>t.bump.forEach((d,si)=>{if(!d)return;const p=t.pos[si+1],v=DV[d],x=cxOf(p[0])+v[0]*s*.5,y=cyOf(p[1])+v[1]*s*.5;
670
+ g.strokeStyle=BAD;g.lineWidth=s*.08;g.lineCap='round';g.beginPath();g.moveTo(x-v[1]*s*.22,y-v[0]*s*.22);g.lineTo(x+v[1]*s*.22,y+v[0]*s*.22);g.stroke()}));
671
+ g.restore();
672
+ }
673
+ // 画在坦克上面的静态标记:被打中的车外面一圈红色短线,刚复活、刚升级的车外面一圈环
674
+ function drawMarksTop(k,alpha){
675
+ if(k<1||alpha<=0)return;
676
+ const S=simulate(k),s=CS,fr=F[k];
677
+ g.save();g.globalAlpha=alpha;
678
+ for(const e of S.fx){
679
+ if(e.type==='hit'){const t=fr.tanks[e.tank];if(!t.alive)continue;const x=cxOf(t.x),y=cyOf(t.y);g.strokeStyle=BAD;g.lineWidth=s*.06;g.lineCap='round';
680
+ for(let k2=0;k2<8;k2++){const a=k2*Math.PI/4+.39;g.beginPath();g.moveTo(x+Math.cos(a)*s*.5,y+Math.sin(a)*s*.5);g.lineTo(x+Math.cos(a)*s*.62,y+Math.sin(a)*s*.62);g.stroke()}}
681
+ else if(e.type==='basehit'){const x=px(e.x),y=py(e.y);g.strokeStyle=BAD;g.lineWidth=s*.07;g.strokeRect(x-s*.03,y-s*.03,s*1.06,s*1.06)}
682
+ else if(e.type==='respawn'){const x=cxOf(e.x),y=cyOf(e.y);g.strokeStyle=PAL[seatOf(e.tank)].l;g.lineWidth=s*.04;g.setLineDash([s*.1,s*.08]);g.beginPath();g.arc(x,y,s*.6,0,7);g.stroke();g.setLineDash([])}
683
+ else if(e.type==='star'){const t=fr.tanks[e.tank];if(!t.alive)continue;g.strokeStyle=GOLD;g.lineWidth=s*.05;g.beginPath();g.arc(cxOf(t.x),cyOf(t.y),s*.6,0,7);g.stroke()}
684
+ }
685
+ g.restore();
686
+ }
687
+
688
+ // ====================================================================
689
+ // 画一帧。k = 帧号;p = 正在播第 k+1 回合的进度(0 表示停在第 k 帧);tl = 停下后过了多久
690
+ // ====================================================================
691
+ function fitCanvas(c){const r=c.getBoundingClientRect(),dpr=window.devicePixelRatio||1,w=Math.max(1,Math.round(r.width*dpr)),h=Math.max(1,Math.round(r.height*dpr));
692
+ if(c.width!==w||c.height!==h){c.width=w;c.height=h}return dpr}
693
+ function drawBoard(k,p,tl){
694
+ fitCanvas(cv);
695
+ const W=cv.width,n=D.size,P=D.params;
696
+ const margin=showCoords?Math.round(W*.032):Math.round(W*.012);
697
+ CS=Math.floor((W-2*margin)/n);OX=Math.round((W-CS*n)/2);OY=OX;
698
+ const anim=p>0&&k<N,A=F[k],S=anim?simulate(k+1):null,s=CS;
699
+ g.setTransform(1,0,0,1,0,0);g.fillStyle='#262b33';g.fillRect(0,0,W,W);
700
+ g.fillStyle='#4a515c';g.fillRect(OX-4,OY-4,CS*n+8,CS*n+8);
701
+ const map=anim?(p<.275?A.map:p<.775?S.maps[0]:S.maps[1]):A.map;
702
+ g.drawImage(terrain(map),OX,OY);
703
+ // 坐标
704
+ if(showCoords){g.font=`${Math.round(margin*.5)}px ${NUMFONT}`;g.fillStyle='#8a94a1';g.textAlign='center';g.textBaseline='middle';
705
+ for(let i=0;i<n;i++){g.fillText(i,cxOf(i),OY/2-1);g.fillText(i,cxOf(i),OY+CS*n+OY/2+1);g.fillText(i,OX/2-1,cyOf(i));g.fillText(i,OX+CS*n+OX/2+1,cyOf(i))}}
706
+ // 地上的记号:出生点、补给点
707
+ D.spawns.forEach((sp,i)=>drawSpawn(g,cxOf(sp[0]),cyOf(sp[1]),s,seatOf(i)));
708
+ const turnNow=A.turn,ns=nextSupply(turnNow);
709
+ const hasStar=(x,y)=>A.stars.some(q=>q[0]===x&&q[1]===y);
710
+ const occupied=(x,y)=>A.tanks.some(t=>t.alive&&t.x===x&&t.y===y);
711
+ for(const[x,y]of D.supply){drawSupply(g,cxOf(x),cyOf(y),s);
712
+ if(!hasStar(x,y)&&ns!=null&&!(anim?false:occupied(x,y)))label(g,String(ns-turnNow),cxOf(x),cyOf(y),s*.34,'#b89a3a',anim&&occupied(x,y)?0:.95,'#0009')}
713
+ // 停着的时候:这一回合留下的痕迹
714
+ const markA=anim?0:clamp((tl-.35)/.4,0,1);
715
+ if(!anim)drawMarks(k,markA);
716
+ // 星星
717
+ const fresh=!anim&&k>=1?simulate(k).fx.filter(e=>e.type==='supply'):[];
718
+ for(const[x,y]of A.stars){const born=fresh.some(e=>e.x===x&&e.y===y)?clamp(tl/.35,0,1):1;
719
+ drawStar(g,cxOf(x),cyOf(y),s*.3*(born<1?.4+.6*born*(2-born):1),born)}
720
+ // 基地
721
+ const baseHp=[0,1].map(q=>anim?Math.max(0,A.base_hp[q]-S.baseT[q].filter(t=>t<=p).length):A.base_hp[q]);
722
+ D.bases.forEach((b,q)=>{const flash=anim&&S.baseT[q].some(t=>p>=t&&p-t<.09);
723
+ drawBase(g,cxOf(b[0]),cyOf(b[1]),s,q,baseHp[q],P.base_hp||12,flash);
724
+ label(g,String(baseHp[q]),cxOf(b[0])+s*.0,cyOf(b[1])+s*.4,s*.26,'#fff',1)});
725
+ // 不在场的车:出生点上画虚影和复活倒计时
726
+ A.tanks.forEach((t,i)=>{if(t.alive)return;const sp=D.spawns[i],x=cxOf(sp[0]),y=cyOf(sp[1]);
727
+ if(occupied(sp[0],sp[1])){label(g,t.respawn_in>0?String(t.respawn_in):'…',x+s*.34,y-s*.34,s*.3,PAL[t.seat].l,1);return}
728
+ drawTank(g,x,y,s,t.type,t.seat,t.seat?'D':'U',{ghost:true,alpha:.55});
729
+ label(g,t.respawn_in>0?String(t.respawn_in):'等',x,y,s*.5,'#fff',1,PAL[t.seat].d)});
730
+ // 坦克
731
+ const sub=p*2,si=Math.min(1,Math.floor(sub)),sf=sub-si;
732
+ const prevFx=k>=1?simulate(k).fx:[];
733
+ A.tanks.forEach((t,i)=>{
734
+ if(!t.alive)return;
735
+ let x=t.x,y=t.y,dir=facing[k][i],hp=t.hp,flash=false,alpha=1,tread=0;
736
+ if(anim){const st=S.T[i],a=st.pos[si],b=st.pos[si+1];x=a[0]+(b[0]-a[0])*sf;y=a[1]+(b[1]-a[1])*sf;
737
+ const bd=st.bump[si];if(bd){const off=.13*Math.sin(Math.PI*Math.min(1,sf*1.7));x+=DV[bd][0]*off;y+=DV[bd][1]*off}
738
+ if(st.fire)dir=st.fire;else if(st.mv[si])dir=st.mv[si];else if(si===1&&st.mv[0])dir=st.mv[0];
739
+ hp=t.hp-st.hitT.filter(q=>q<=p).length;flash=st.hitT.some(q=>p>=q&&p-q<.07);tread=(x+y)*4}
740
+ else if(prevFx.some(e=>e.type==='respawn'&&e.tank===i))alpha=clamp(tl/.3,0,1);
741
+ const X=cxOf(x),Y=cyOf(y);
742
+ drawTank(g,X,Y,s,t.type,t.seat,dir,{flash,dead:hp<=0,alpha,tread});
743
+ if(alpha>.5)drawHud(g,X,Y,s,{hp:Math.max(0,hp),maxhp:maxHp(t),level:t.level,cap:t.bullet_cap,out:Math.min(t.bullet_cap,t.bullets_out+(anim&&S.T[i].fire?1:0)),reload:!anim&&t.reload_left>0});
744
+ });
745
+ if(!anim)drawMarksTop(k,markA);
746
+ // 子弹
747
+ if(anim){for(const b of S.bullets){if(sub>=b.endS)continue;const d=DV[b.dir];
748
+ if(b.fresh&&sub<.3)continue; // 刚出膛的头一小段藏在车身下面
749
+ drawBullet(g,cxOf(b.x0+d[0]*sub),cyOf(b.y0+d[1]*sub),s,b.dir,b.owner,b.steel,b.fresh?Math.min(sub-.3,1.1):1.1)}}
750
+ else for(const b of A.bullets)drawBullet(g,cxOf(b.x),cyOf(b.y),s,b.dir,b.owner,b.steel,1.1);
751
+ // 特效
752
+ if(anim){for(const e of prevFx)drawFx(e,1+p-e.t);for(const e of S.fx)drawFx(e,p-e.t)}
753
+ else if(tl<TAIL_MAX)for(const e of prevFx)drawFx(e,1+tl-e.t);
754
+ return baseHp;
755
+ }
756
+
757
+ // ====================================================================
758
+ // 侧栏
759
+ // ====================================================================
760
+ const side={};
761
+ function miniTank(canvas,type,seat){const dpr=window.devicePixelRatio||1,w=Math.round(52*dpr);canvas.width=canvas.height=w;const c=canvas.getContext('2d');drawTank(c,w/2,w/2,w*.86,type,seat,'U')}
762
+ function buildStatic(){
763
+ const P=D.params,unit=D.budget&&D.budget.kind==='cpu'?'CPU 毫秒':'毫秒';
764
+ for(const seat of[0,1]){
765
+ const el=$('team'+seat),hpn=P.base_hp||12;
766
+ el.innerHTML=`<div class="thead"><span class="mark"></span><span class="tname">${esc(D.names[seat])}</span><span class="tres" data-k="res"></span><span class="tflt" data-k="flt"></span><span class="tseat">${seat} 号位 · ${seat?'上方':'下方'}</span></div>
767
+ <div class="baserow"><span class="lbl">基地</span><div class="hpbar" data-k="hpbar">${'<i></i>'.repeat(hpn)}</div><span class="hpnum"><span data-k="hp"></span><small>/${hpn}</small></span></div>
768
+ <div class="stats"><div><b data-k="kills"></b><span>击毁</span></div><div><b data-k="stars"></b><span>吃星</span></div><div><b data-k="dmg"></b><span>打中对方基地</span></div><div data-k="msbox"><b data-k="ms"></b><span>本回合思考</span></div></div>
769
+ ${[0,1,2].map(j=>{const i=seat*3+j;return `<div class="trow" data-k="row${i}"><canvas data-k="cv${i}"></canvas>
770
+ <div><div class="tl1"><b>${TYPE_CN[typeOf(i)]}</b><span class="pips" data-k="pips${i}"></span><span class="lv" data-k="lv${i}"></span><span class="pos" data-k="pos${i}"></span></div>
771
+ <div class="tl2" data-k="st${i}"></div></div><div class="ord" data-k="ord${i}"></div></div>`}).join('')}`;
772
+ el.querySelectorAll('[data-k]').forEach(n=>{side[n.dataset.k+(/\d$/.test(n.dataset.k)?'':'_'+seat)]={el:n,v:null}});
773
+ for(let j=0;j<3;j++)miniTank(side['cv'+(seat*3+j)].el,typeOf(seat*3+j),seat);
774
+ }
775
+ const res=D.result||{};
776
+ const style=D.style==null?'':` · 风格 ${STYLE_CN[D.style]||D.style}`;
777
+ $('meta').innerHTML=`<b>${esc(D.names[0])}</b> 对 <b>${esc(D.names[1])}</b> · 种子 ${esc(D.seed)}${style} · ${D.size}×${D.size} · 共 ${N} 回合 · ${esc(D.engine||'')}`
778
+ +(D.consistent===false?'<span class="warnchip" title="重新结算出的终局和回放里记的不一样,这份回放可能不是用当前的引擎打的">回放与引擎不一致</span>':'');
779
+ document.title=`${D.names[0]} 对 ${D.names[1]} · 种子 ${D.seed} · 坦克大战回放`;
780
+ $('thinkunit').textContent=unit;
781
+ $('tllegend').innerHTML=`<span class="tk s1"><i></i>${esc(D.names[1])}</span>(上排)<br><b>✕</b>击毁 <b>▮</b>打中基地 <b class="c-gold">★</b>吃星 <b class="c-bad">!</b>故障<br><span class="tk s0"><i></i>${esc(D.names[0])}</span>(下排)`;
782
+ drawLegend();
783
+ }
784
+ function put(key,html){const o=side[key];if(o&&o.v!==html){o.v=html;o.el.innerHTML=html}}
785
+ function cls(key,name,on){const o=side[key];if(o)o.el.classList.toggle(name,!!on)}
786
+ function orderHtml(i,turnFrame,prevFrame){ // 第 i 辆车在这一回合的命令
787
+ if(!turnFrame||!turnFrame.orders)return['—',true];
788
+ const seat=seatOf(i),o=turnFrame.orders[seat][i%3]||'S',was=prevFrame.tanks[i].alive,ev=turnFrame.events;
789
+ if(!was)return[`<small>不在场</small>`,true];
790
+ if(o==='S')return['不动',true];
791
+ if(o[0]==='F'){const ok=ev.some(e=>e.type==='fire'&&e.tank===i);return[`开炮 <span class="ar">${ARROW[o[1]]}</span>`+(ok?'':' <span class="x" title="装填中或子弹已达上限,这一炮没有打出去">未打出</span>'),false]}
792
+ let h='移动 ';
793
+ for(let s=0;s<o.length;s++){const blocked=ev.some(e=>e.type==='blocked'&&e.tank===i&&e.sub===s+1);h+=`<span class="ar">${ARROW[o[s]]}</span>`+(blocked?'<span class="x" title="这一步没走成">✕</span>':'')}
794
+ return[h,false];
795
+ }
796
+ function updateSide(baseHp){
797
+ const anim=prog>0&&cur<N,A=F[cur],show=anim?cur+1:cur,B=F[show],S=anim?simulate(cur+1):null,P=D.params,res=D.result||{};
798
+ const total=N,ended=cur===N&&!anim;
799
+ for(const seat of[0,1]){
800
+ const hp=baseHp[seat],hpn=P.base_hp||12;
801
+ put('hp_'+seat,String(hp));
802
+ const key='hpbar_'+seat,o=side[key],sig=hp+'/'+(anim&&S.baseT[seat].some(t=>prog>=t&&prog-t<.12)?1:0);
803
+ if(o.v!==sig){o.v=sig;[...o.el.children].forEach((n,i)=>{n.className=i<hp?'on':(i<A.base_hp[seat]&&sig.endsWith('1')?'hit':'')})}
804
+ put('kills_'+seat,String(A.kills[seat]));put('stars_'+seat,String(A.stars_eaten[seat]));put('dmg_'+seat,String(A.base_dmg?A.base_dmg[seat]:'–'));
805
+ const ms=B.used_ms?B.used_ms[seat]:null,bud=budgetOf(show);
806
+ put('ms_'+seat,fmtMs(ms));cls('msbox_'+seat,'near',ms!=null&&ms>=bud*.8&&ms<=bud);cls('msbox_'+seat,'over',ms!=null&&ms>bud);
807
+ put('flt_'+seat,(B.faults||[]).filter(f=>f.seat===seat).map(f=>'本回合'+esc(faultKind(f.kind))+',三辆都不动')[0]||'');
808
+ let r='',rc='';
809
+ if(ended){if(res.winner===seat){r='胜';rc='win'}else if(res.winner<0||res.winner==null){r='平';rc='draw'}else{r='负';rc='lose'}}
810
+ put('res_'+seat,r);const ro=side['res_'+seat].el;if(ro.dataset.c!==rc){ro.dataset.c=rc;ro.className='tres '+rc;ro.style.display=r?'':'none'}
811
+ for(let j=0;j<3;j++){
812
+ const i=seat*3+j,t=A.tanks[i];
813
+ const hpNow=anim?Math.max(0,t.hp-S.T[i].hitT.filter(q=>q<=prog).length):t.hp,mh=maxHp(t);
814
+ cls('row'+i,'dead',!t.alive);
815
+ put('pips'+i,t.alive?Array.from({length:mh},(_,q)=>`<i class="${q<hpNow?'on':''}"></i>`).join(''):'');
816
+ put('lv'+i,t.alive?(t.level>0?'★'.repeat(t.level)+(t.level>=2&&P.level2_steel!==0?' 破钢':''):''):'');
817
+ put('pos'+i,t.alive?`(${t.x},${t.y})`:'');
818
+ let st;
819
+ if(!t.alive)st=`<span class="ded">已被毁</span> · ${t.respawn_in>0?t.respawn_in+' 回合后复活':'等出生点空出来'}`;
820
+ else{const ready=t.bullet_cap-t.bullets_out;st=`血 ${hpNow}/${mh} · 子弹 ${ready}/${t.bullet_cap}`+(t.reload_left>0?` · <span class="rel">装填中</span>`:t.can_fire?' · 可开炮':' · 子弹都在飞')}
821
+ put('st'+i,st);
822
+ const[oh,none]=show>=1?orderHtml(i,B,F[show-1]):['—',true];
823
+ put('ord'+i,oh);cls('ord'+i,'none',none);
824
+ }
825
+ }
826
+ // 回合信息
827
+ const tb=anim?`第 ${show} 回合<small> / ${total}</small>`:cur===0?`开局<small> / 共 ${total} 回合</small>`:`第 ${cur} 回合<small> / ${total}</small>`;
828
+ if(side._tb!==tb){side._tb=tb;$('turnbig').innerHTML=tb}
829
+ const ph=anim?(prog<.06?0:prog<.5?1:2):(cur>0?3:-1);
830
+ if(side._ph!==ph){side._ph=ph;[...$('phases').children].forEach((n,i)=>n.classList.toggle('on',i===ph))}
831
+ const ns=nextSupply(A.turn),sl=ns==null?'不会再出补给了':`下一批补给:<b>${ns-A.turn}</b> 回合后(第 ${ns} 回合末) · 场上星星 <b>${A.stars.length}</b>`;
832
+ if(side._sl!==sl){side._sl=sl;$('supplyline').innerHTML=sl}
833
+ const lbl=`${anim?show:cur}<small> / ${total}</small>`;
834
+ if(side._lbl!==lbl){side._lbl=lbl;$('turnlabel').innerHTML=lbl}
835
+ if(show!==shownTurn){shownTurn=show;updateEvents(show);updateDebug(show);drawThink()}
836
+ }
837
+ const budgetOf=turn=>D.budget?(turn<=1?D.budget.first_ms:D.budget.turn_ms):Infinity;
838
+ const tk=i=>`<span class="tk s${seatOf(i)}"><i></i>${TYPE_CN[typeOf(i)]}</span>`;
839
+ const sideName=s=>`<span class="tk s${s}"><i></i>${esc(D.names[s])}</span>`;
840
+ function eventHtml(e){
841
+ switch(e.type){
842
+ case 'fire':return['➤','',`${tk(e.tank)} 向 ${ARROW[e.dir]} 开炮`];
843
+ case 'blocked':return['⊘','c-mut',`${tk(e.tank)} 没走成`];
844
+ case 'cancel':return['✕','c-ink2',`子弹在 (${e.x},${e.y}) ${e.how==='cross'?'对穿':'同格'}对消`];
845
+ case 'hit':return['✸','c-bad',`${tk(e.tank)} 在 (${e.x},${e.y}) 被 ${tk(e.by)} 打中`+(e.swap?'(迎着子弹走)':'')+(seatOf(e.tank)===seatOf(e.by)?' <span class="c-bad">友军误伤</span>':'')];
846
+ case 'base_hit':return['▮','c-bad big',`${sideName(e.seat)} 的基地被 ${tk(e.by)} 打中`+(seatOf(e.by)===e.seat?' <span class="c-bad">打了自己的基地</span>':'')];
847
+ case 'wall':return['▪','c-ink2',`(${e.x},${e.y}) 的${e.kind==='brick'?'砖墙':'钢墙'}被打掉`];
848
+ case 'death':return['☠','c-bad big',`${tk(e.tank)} 在 (${e.x},${e.y}) 被毁`+(e.by>=0?`(压在出生点上,被 ${tk(e.by)} 复活炸毁)`:'')];
849
+ case 'respawn':return['↺','',`${tk(e.tank)} 在 (${e.x},${e.y}) 复活`];
850
+ case 'bullet_cleared':return['·','c-mut',`(${e.x},${e.y}) 上的子弹因 ${tk(e.tank)} 复活而消失`];
851
+ case 'star':return['★','c-gold big',`${tk(e.tank)} 吃到星星,升到 ${e.level} 级`+(e.level>=2&&D.params.level2_steel!==0?'(能破钢)':'')];
852
+ case 'supply':return['☆','c-gold',`(${e.x},${e.y}) 出了一颗星星`];
853
+ case 'base_regen':return['+','',`${sideName(e.seat)} 的基地回 1 血`];
854
+ default:return['·','c-mut',esc(e.text||e.type)];
855
+ }
856
+ }
857
+ function updateEvents(turn){
858
+ const f=F[turn];$('evturn').textContent=turn?`第 ${turn} 回合`:'开局';
859
+ if(!turn){$('events').innerHTML='<div class="none">开局。0 号位在下方,1 号位在上方。</div>';return}
860
+ const groups=[['开炮',[]],['小步 1',[]],['小步 2',[]],['回合末',[]]];
861
+ for(const e of f.events){const gi=e.type==='fire'?0:e.sub===1?1:e.sub===2?2:3;groups[gi][1].push(e)}
862
+ let h='';
863
+ for(const[name,list]of groups){if(!list.length)continue;h+=`<div class="grp">${name}</div>`;
864
+ for(const e of list){const[ic,c,t]=eventHtml(e);h+=`<div class="ev ${c.includes('big')?'big':''}"><span class="ic ${c.replace('big','')}">${ic}</span><span>${t}</span></div>`}}
865
+ $('events').innerHTML=h||'<div class="none">这一回合没有发生什么。</div>';
866
+ $('events').scrollTop=0;
867
+ }
868
+ function updateDebug(turn){
869
+ $('dbgturn').textContent=turn?`第 ${turn} 回合`:'';
870
+ let h='';
871
+ const ftext=f=>`回合 ${f.turn} · ${esc(D.names[f.seat])}:${esc(f.detail||faultKind(f.kind))}`;
872
+ const res=D.result||{},now=faultList.filter(f=>f.turn===turn);
873
+ for(const f of now)h+=`<div class="fault now" data-turn="${f.turn}">${ftext(f)}</div>`;
874
+ const lines=stderrByTurn[turn]||[];
875
+ h+=`<div class="sub">这一回合的调试输出${lines.length?'':':无'}</div>`;
876
+ for(const[seat,line]of lines)h+=`<div class="ln"><span class="tk s${seat}"><i></i></span>${esc(line)}</div>`;
877
+ if(faultList.length||(res.bots||[]).some(b=>b&&b.dead)){h+=`<div class="sub">整局的故障(共 ${faultList.length} 条,点一下跳过去)</div>`;
878
+ (res.bots||[]).forEach((b,s)=>{if(b&&b.dead)h+=`<div class="fault now" data-turn="${N}">${esc(D.names[s])} 被判负:${esc(b.dead)}</div>`});
879
+ for(const f of faultList)h+=`<div class="fault ${f.turn===turn?'now':''}" data-turn="${f.turn}">${ftext(f)}</div>`}
880
+ const total=(D.stderr||[]).reduce((a,l)=>a+(l?l.length:0),0);
881
+ if(!faultList.length&&!total)h=`<div class="none c-mut">这局没有故障,两边也都没有调试输出。<br>bot 写到标准错误的内容会按回合显示在这里。</div>`;
882
+ $('debug').innerHTML=h;
883
+ }
884
+ $('debug').addEventListener('click',e=>{const n=e.target.closest('.fault');if(n)seek(clamp(+n.dataset.turn,0,N))});
885
+
886
+ function drawLegend(){
887
+ const c=$('legend');if(!c.clientWidth)return;const items=[['砖墙','一炮打掉'],['钢墙','2 级才能打掉'],['水','挡车不挡子弹'],['补给点','定时出星星'],['出生点','复活的位置'],['星星','吃了升一级']];
888
+ const cssW=c.parentElement.clientWidth-4,fs=parseFloat(getComputedStyle(document.body).fontSize),cols=3,rowH=fs*2.6,dpr=window.devicePixelRatio||1;
889
+ c.style.height=rowH*2+'px';c.width=Math.round(cssW*dpr);c.height=Math.round(rowH*2*dpr);
890
+ const x=c.getContext('2d');x.scale(dpr,dpr);
891
+ items.forEach(([name,note],i)=>{const cx=(i%cols)*cssW/cols+4,cy=Math.floor(i/cols)*rowH+3,s=rowH-8;
892
+ if(i===0)drawBrick(x,cx,cy,s);else if(i===1)drawSteel(x,cx,cy,s);else if(i===2)drawWater(x,cx,cy,s);
893
+ else{drawEmpty(x,cx,cy,s);if(i===3)drawSupply(x,cx+s/2,cy+s/2,s);else if(i===4)drawSpawn(x,cx+s/2,cy+s/2,s,1);else drawStar(x,cx+s/2,cy+s/2,s*.34)}
894
+ x.textAlign='left';x.textBaseline='middle';x.fillStyle='#e9edf3';x.font=`600 ${fs*.88}px ${FONT}`;x.fillText(name,cx+s+7,cy+s*.28);
895
+ x.fillStyle='#7c8794';x.font=`${fs*.72}px ${FONT}`;x.fillText(note,cx+s+7,cy+s*.74)});
896
+ }
897
+
898
+ // ====================================================================
899
+ // 思考用量小图:两方各一行,同一把纵轴
900
+ // ====================================================================
901
+ let thinkGeo=null;
902
+ function drawThink(){
903
+ const c=$('think');if(!D||!c.clientWidth)return;
904
+ const dpr=fitCanvas(c),x=c.getContext('2d'),W=c.width/dpr,H=c.height/dpr,fs=parseFloat(getComputedStyle(document.body).fontSize);
905
+ x.setTransform(dpr,0,0,dpr,0,0);x.clearRect(0,0,W,H);
906
+ const bud=D.budget||{},turnMs=bud.turn_ms||50;
907
+ const val=(k,s)=>F[k].used_ms&&F[k].used_ms[s]!=null?F[k].used_ms[s]:null;
908
+ let mx=0;for(let k=2;k<=N;k++)for(const s of[0,1]){const v=val(k,s);if(v!=null&&v>mx)mx=v}
909
+ const showBud=mx>=turnMs*.4;
910
+ let ymax;if(showBud)ymax=Math.max(turnMs*1.18,mx*1.06);else{const raw=Math.max(mx*1.25,1e-3),e=Math.pow(10,Math.floor(Math.log10(raw)));ymax=Math.ceil(raw/e)*e}
911
+ const padL=fs*2.9,padR=6,gap=fs*.9,top=fs*1.25,ph=(H-top*2-gap-fs*1.0)/2,pw=W-padL-padR,step=pw/Math.max(N,1),bw=Math.max(1,Math.min(step-1,step*.72));
912
+ const shown=prog>0&&cur<N?cur+1:cur;
913
+ thinkGeo={padL,step,W};
914
+ [1,0].forEach((seat,row)=>{
915
+ const y0=top+row*(ph+top+gap),base=y0+ph,p=PAL[seat];
916
+ // 标题:色块 + 名字(文字用正文色,不用系列色)
917
+ x.fillStyle=p.m;x.fillRect(padL,y0-fs*.95,fs*.7,fs*.7);if(seat){x.fillStyle='#0d1626';x.fillRect(padL+fs*.18,y0-fs*.77,fs*.34,fs*.34)}
918
+ x.fillStyle='#b3bcc8';x.font=`${fs*.78}px ${FONT}`;x.textAlign='left';x.textBaseline='middle';
919
+ const b=(D.result&&D.result.bots||[])[seat]||{};
920
+ const v1=val(1,seat);
921
+ x.fillText(`${D.names[seat]} 首回合 ${fmtMs(v1)} / ${bud.first_ms||'?'},之后最多 ${fmtMs(b.max_ms!=null?b.max_ms:mx)} / ${turnMs}`,padL+fs*1.0,y0-fs*.58);
922
+ // 轴
923
+ x.strokeStyle='#2a313c';x.lineWidth=1;x.beginPath();x.moveTo(padL,base+.5);x.lineTo(W-padR,base+.5);x.stroke();
924
+ x.fillStyle='#7c8794';x.font=`${fs*.68}px ${NUMFONT}`;x.textAlign='right';
925
+ x.fillText('0',padL-4,base-2);if(!showBud)x.fillText(fmtMs(ymax),padL-4,y0+fs*.3);
926
+ if(showBud){const yb=base-ph*turnMs/ymax;x.setLineDash([4,3]);x.strokeStyle='#8a94a1';x.beginPath();x.moveTo(padL,yb+.5);x.lineTo(W-padR,yb+.5);x.stroke();x.setLineDash([]);
927
+ x.fillText(String(turnMs),padL-4,yb)}
928
+ for(let k=1;k<=N;k++){const v=val(k,seat);if(v==null)continue;const bg=budgetOf(k),ratio=v/bg,near=ratio>=.8&&ratio<=1,over=ratio>1;
929
+ const h=Math.max(1,Math.min(1,v/ymax)*ph),bx=padL+(k-.5)*step-bw/2;
930
+ x.fillStyle=over?BAD:near?WARN:p.m;x.fillRect(bx,base-h,bw,h);
931
+ if(k===1&&v>ymax){x.fillStyle='#0c0e11';x.fillRect(bx-1,base-ph*.55,bw+2,2)} // 首回合单独的预算,柱子截断
932
+ if(near||over){const mxx=bx+bw/2,my=y0+2;x.fillStyle=over?BAD:WARN;x.beginPath();x.moveTo(mxx-4,my);x.lineTo(mxx+4,my);x.lineTo(mxx,my+6);x.closePath();
933
+ if(over)x.fill();else{x.strokeStyle=WARN;x.lineWidth=1.3;x.stroke()}}}
934
+ });
935
+ // 当前回合、悬停的回合
936
+ const line=(k,col)=>{if(k<1)return;const xx=padL+(k-.5)*step;x.strokeStyle=col;x.lineWidth=1;x.beginPath();x.moveTo(xx+.5,top-2);x.lineTo(xx+.5,H-fs*1.0);x.stroke()};
937
+ if(hoverThink>0)line(hoverThink,'#7c8794');line(shown,'#fff');
938
+ x.fillStyle='#7c8794';x.font=`${fs*.68}px ${NUMFONT}`;x.textAlign='center';x.textBaseline='alphabetic';
939
+ const every=N>150?50:N>60?20:N>20?10:5;for(let k=every;k<=N;k+=every)x.fillText(k,padL+(k-.5)*step,H-2);
940
+ x.textAlign='left';x.fillText('回合',2,H-2);
941
+ const over=[],near=[];for(let k=1;k<=N;k++)for(const s of[0,1]){const v=val(k,s);if(v==null)continue;const r=v/budgetOf(k);if(r>1)over.push(k);else if(r>=.8)near.push(k)}
942
+ $('thinknote').innerHTML=(showBud?`虚线 = 每回合预算 ${turnMs}。`:`纵轴顶端 ${fmtMs(ymax)},离每回合预算 ${turnMs} 还很远。`)
943
+ +(over.length?` <span class="c-bad">▼ 超出预算 ${over.length} 回合</span>`:'')+(near.length?` <span style="color:var(--warn)">▽ 接近预算(≥80%)${near.length} 回合</span>`:'')
944
+ +(!over.length&&!near.length?' 没有接近或超出预算的回合。':'')+' 首回合的预算单独算。';
945
+ }
946
+ function thinkTurnAt(ev){const r=$('think').getBoundingClientRect();if(!thinkGeo)return-1;const k=Math.floor((ev.clientX-r.left-thinkGeo.padL)/thinkGeo.step)+1;return k>=1&&k<=N?k:-1}
947
+ $('think').addEventListener('mousemove',ev=>{const k=thinkTurnAt(ev);if(k!==hoverThink){hoverThink=k;drawThink()}
948
+ if(k<1)return hideTip();const f=F[k],b=budgetOf(k);
949
+ showTip(ev,`第 ${k} 回合(预算 ${b})\n`+[1,0].map(s=>{const v=f.used_ms?f.used_ms[s]:null;return `${D.names[s]}:${fmtMs(v)}`+(v!=null&&v>b?' 超出预算':v!=null&&v>=b*.8?' 接近预算':'')}).join('\n'))});
950
+ $('think').addEventListener('mouseleave',()=>{hoverThink=-1;drawThink();hideTip()});
951
+ $('think').addEventListener('click',ev=>{const k=thinkTurnAt(ev);if(k>=0)seek(k)});
952
+ function showTip(ev,text){const t=$('tip');t.textContent=text;t.style.display='block';const w=t.offsetWidth,h=t.offsetHeight;
953
+ t.style.left=clamp(ev.clientX+12,4,innerWidth-w-4)+'px';t.style.top=clamp(ev.clientY-h-10,4,innerHeight-h-4)+'px'}
954
+ function hideTip(){$('tip').style.display='none'}
955
+
956
+ // ====================================================================
957
+ // 时间轴
958
+ // ====================================================================
959
+ let tlGeo=null;
960
+ function drawTimeline(){
961
+ const c=$('timeline');if(!D||!c.clientWidth)return;
962
+ const dpr=fitCanvas(c),x=c.getContext('2d'),W=c.width/dpr,H=c.height/dpr,fs=parseFloat(getComputedStyle(document.body).fontSize);
963
+ x.setTransform(dpr,0,0,dpr,0,0);x.clearRect(0,0,W,H);
964
+ const padL=10,padR=10,w=W-padL-padR,X=t=>padL+(N?t/N:0)*w,mid=H/2,lane=[mid+H*.25,mid-H*.25];
965
+ tlGeo={padL,w};
966
+ x.fillStyle='#1b2029';x.fillRect(padL-4,3,w+8,H-6);
967
+ x.fillStyle='#232a35';x.fillRect(padL,mid-3,w,6);
968
+ const pos=cur+prog;
969
+ x.fillStyle='#55647a';x.fillRect(padL,mid-3,X(pos)-padL,6);
970
+ const every=N>150?50:N>60?20:N>20?10:5;
971
+ x.font=`${fs*.74}px ${NUMFONT}`;x.textAlign='center';x.textBaseline='middle';
972
+ for(let k=0;k<=N;k+=every){x.fillStyle='#3a4452';x.fillRect(X(k)-.5,mid-6,1,12);if(k&&X(N)-X(k)>fs*1.2){x.fillStyle='#0c0e11';x.fillText(k,X(k),mid);x.fillStyle='#9aa5b3';x.fillText(k,X(k),mid+.5)}}
973
+ // 标记:形状分种类,颜色和上下排分两方
974
+ const r=Math.max(3,H*.085);
975
+ for(const k of keyFrames){const cnt=[{},{}];
976
+ for(const e of keys[k]){const y=lane[e.lane],xx=X(k),p=PAL[e.lane];cnt[e.lane][e.kind]=(cnt[e.lane][e.kind]||0)+1;if(cnt[e.lane][e.kind]>1)continue;
977
+ const yy=y+(e.kind==='base'?(e.lane?-1:1)*H*.13:0);
978
+ if(e.kind==='kill'){x.strokeStyle=p.l;x.lineWidth=2.2;x.lineCap='round';x.beginPath();x.moveTo(xx-r,yy-r);x.lineTo(xx+r,yy+r);x.moveTo(xx+r,yy-r);x.lineTo(xx-r,yy+r);x.stroke()}
979
+ else if(e.kind==='base'){x.fillStyle=p.m;x.fillRect(xx-1.5,yy-H*.07,3,H*.14)}
980
+ else if(e.kind==='star'){starPath(x,xx,yy,r*1.25);x.fillStyle=GOLD;x.fill();x.lineWidth=1.2;x.strokeStyle=p.m;x.stroke()}
981
+ else{x.fillStyle=BAD;x.font=`800 ${r*3}px ${FONT}`;x.fillText('!',xx,yy);x.font=`${fs*.74}px ${NUMFONT}`}}}
982
+ if(hoverTl>=0){x.fillStyle='#ffffff55';x.fillRect(X(hoverTl)-.5,3,1,H-6)}
983
+ const hx=X(pos);x.fillStyle='#fff';x.fillRect(hx-1,2,2,H-4);x.beginPath();x.moveTo(hx-6,1);x.lineTo(hx+6,1);x.lineTo(hx,9);x.closePath();x.fill();
984
+ x.beginPath();x.moveTo(hx-6,H-1);x.lineTo(hx+6,H-1);x.lineTo(hx,H-9);x.closePath();x.fill();
985
+ }
986
+ function tlTurnAt(ev){const r=$('timeline').getBoundingClientRect();if(!tlGeo)return 0;return clamp(Math.round((ev.clientX-r.left-tlGeo.padL)/tlGeo.w*N),0,N)}
987
+ {const c=$('timeline');let drag=false;
988
+ c.addEventListener('pointerdown',ev=>{if(!D)return;drag=true;c.setPointerCapture(ev.pointerId);seek(tlTurnAt(ev))});
989
+ c.addEventListener('pointermove',ev=>{if(!D)return;const k=tlTurnAt(ev);if(drag)seek(k);
990
+ if(k!==hoverTl){hoverTl=k;dirty=true}
991
+ const list=keys[k]||[];showTip(ev,`第 ${k} 回合`+(list.length?'\n'+[...new Set(list.map(e=>e.text))].join('\n'):''))});
992
+ c.addEventListener('pointerup',()=>{drag=false});c.addEventListener('pointercancel',()=>{drag=false});
993
+ c.addEventListener('pointerleave',()=>{hoverTl=-1;dirty=true;hideTip()})}
994
+
995
+ // ====================================================================
996
+ // 结束画面
997
+ // ====================================================================
998
+ function updateEnd(){
999
+ const on=!!D&&showEnd&&ARGS.end!=='0'&&cur===N&&prog===0&&!playing&&tail>=Math.min(TAIL_MAX,.9);
1000
+ const el=$('endcard');if(on===el.classList.contains('on'))return;
1001
+ el.classList.toggle('on',on);if(!on)return;
1002
+ const res=D.result||{},w=res.winner,last=F[N],bots=res.bots||[];
1003
+ const who=w>=0?`<span class="tk s${w}">${esc(D.names[w])}</span> 获胜`:'平局';
1004
+ let why=reasonText(res.reason||last.reason);
1005
+ if(res.reason==='fault'){const dead=bots.map((b,s)=>b&&b.dead?`${esc(D.names[s])}:${esc(b.dead)}`:'').filter(Boolean);why=(w>=0?'对手故障判负':'双方同一回合故障判负')+(dead.length?'<br><small class="c-bad">'+dead.join(';')+'</small>':'')}
1006
+ const row=(k,a,b)=>`<tr><td class="l">${a}</td><td class="k">${k}</td><td class="r">${b}</td></tr>`;
1007
+ const bh=res.base_hp||last.base_hp,ki=res.kills||last.kills,st=res.stars_eaten||last.stars_eaten;
1008
+ $('endin').innerHTML=`<div class="ttl">对局结束</div><div class="who">${who}</div><div class="why">${why}</div>
1009
+ <table>${row('','<span class="tk s0"><i></i>'+esc(D.names[0])+'</span>','<span class="tk s1"><i></i>'+esc(D.names[1])+'</span>')}${row('基地血量',bh[0],bh[1])}${row('击毁',ki[0],ki[1])}${row('吃星',st[0],st[1])}</table>
1010
+ <div>共 ${res.turns!=null?res.turns:N} 回合 · 种子 ${esc(D.seed)}</div>
1011
+ <div style="margin-top:1em"><button id="bendhide">看终局画面(E)</button> <button id="bendre">从头重播</button></div>`;
1012
+ $('bendhide').onclick=()=>{showEnd=false;dirty=true};
1013
+ $('bendre').onclick=()=>{seek(0);togglePlay(true)};
1014
+ }
1015
+
1016
+ // ====================================================================
1017
+ // 播放控制
1018
+ // ====================================================================
1019
+ function seek(k){if(!D)return;holdTail=false;cur=clamp(Math.round(k),0,N);prog=0;tail=TAIL_MAX;playing=false;stepping=false;showEnd=true;dirty=true;syncPlay()}
1020
+ function togglePlay(on){if(!D)return;holdTail=false;const want=on==null?!(playing&&!stepping):on;
1021
+ if(want){if(cur>=N){cur=0;prog=0}playing=true;stepping=false;showEnd=true}else{playing=false;stepping=false;if(prog>0){prog=0;tail=TAIL_MAX}}
1022
+ dirty=true;syncPlay()}
1023
+ function stepFwd(){if(!D||cur>=N)return;holdTail=false;if(playing&&stepping){cur++;prog=0;if(cur>=N){seek(N);return}}playing=true;stepping=true;dirty=true;syncPlay()} // 播一回合的动画后停下
1024
+ function keyJump(dir){if(!D)return;const pos=prog>0?cur+(dir>0?0:1):cur;const list=dir>0?keyFrames.filter(k=>k>pos):keyFrames.filter(k=>k<pos);
1025
+ if(list.length)seek(dir>0?list[0]:list[list.length-1]);else seek(dir>0?N:0)}
1026
+ function syncPlay(){$('bplay').textContent=playing&&!stepping?'⏸ 暂停':'▶ 播放'}
1027
+ function setSpeed(v){speed=v;const s=$('speed');if(+s.value!==v){s.value=String(v)}}
1028
+ function frame(t){
1029
+ const dt=Math.min(.1,(t-lastT)/1000)||0;lastT=t;
1030
+ if(D){
1031
+ if(playing){prog+=dt*speed;
1032
+ while(prog>=1){cur++;prog-=1;if(cur>=N||stepping){cur=Math.min(cur,N);prog=0;tail=0;playing=false;stepping=false;syncPlay();break}}
1033
+ dirty=true}
1034
+ else if(tail<TAIL_MAX&&!holdTail){tail=Math.min(TAIL_MAX,tail+dt*Math.max(speed,1.2));dirty=true}
1035
+ if(dirty){dirty=false;paint()}
1036
+ }
1037
+ requestAnimationFrame(frame);
1038
+ }
1039
+ function paint(){const hp=drawBoard(cur,prog,tail);updateSide(hp);drawTimeline();updateEnd()}
1040
+
1041
+ // ====================================================================
1042
+ // 布局:棋盘尽量大,两侧栏各至少 21em;窗口窄了改成上下排
1043
+ // ====================================================================
1044
+ function layout(){
1045
+ const fs=parseFloat(getComputedStyle(document.body).fontSize),vw=innerWidth,vh=innerHeight;
1046
+ const narrow=vw<fs*21*2+420||vh<430;
1047
+ document.body.classList.toggle('narrow',narrow);
1048
+ let size;
1049
+ if(narrow)size=Math.max(260,Math.min(vw-fs*1.5,vh-fs*9));
1050
+ else{const m=document.querySelector('main'),avail=m.clientHeight-fs*1.5;size=Math.max(260,Math.min(avail,vw-fs*21*2-fs*3))}
1051
+ document.documentElement.style.setProperty('--board',Math.floor(size)+'px');
1052
+ terrainKey='';dirty=true;
1053
+ if(D){drawLegend();drawThink();for(const seat of[0,1])for(let j=0;j<3;j++)miniTank(side['cv'+(seat*3+j)].el,typeOf(seat*3+j),seat);paint()}
1054
+ }
1055
+ addEventListener('resize',layout);
1056
+
1057
+ // ====================================================================
1058
+ // 文件、按钮、键盘
1059
+ // ====================================================================
1060
+ async function openFile(file){
1061
+ try{
1062
+ let text;
1063
+ if(/\.gz$/i.test(file.name)&&window.DecompressionStream)text=await new Response(file.stream().pipeThrough(new DecompressionStream('gzip'))).text();
1064
+ else text=await file.text();
1065
+ if(load(JSON.parse(text)))seek(0);
1066
+ }catch(exc){alert('文件读不了:'+exc.message)}
1067
+ }
1068
+ $('file').addEventListener('change',e=>{if(e.target.files[0])openFile(e.target.files[0]);e.target.value=''});
1069
+ $('bopen').onclick=$('bopen2').onclick=()=>$('file').click();
1070
+ addEventListener('dragover',e=>{e.preventDefault();document.body.classList.add('dragging')});
1071
+ addEventListener('dragleave',()=>document.body.classList.remove('dragging'));
1072
+ addEventListener('drop',e=>{e.preventDefault();document.body.classList.remove('dragging');const f=e.dataTransfer.files[0];if(f)openFile(f)});
1073
+ $('bfirst').onclick=()=>seek(0);$('blast').onclick=()=>seek(N);
1074
+ $('bprev').onclick=()=>seek(prog>0?cur:cur-1);$('bnext').onclick=stepFwd;
1075
+ $('bpk').onclick=()=>keyJump(-1);$('bnk').onclick=()=>keyJump(1);
1076
+ $('bplay').onclick=()=>togglePlay();
1077
+ $('speed').innerHTML=SPEEDS.map(v=>`<option value="${v}">${v} 回合/秒</option>`).join('');
1078
+ $('speed').onchange=e=>{setSpeed(+e.target.value);e.target.blur()};
1079
+ $('endcard').addEventListener('click',e=>{if(e.target===$('endcard')){showEnd=false;dirty=true}});
1080
+ $('helpin').innerHTML=`<b style="font-size:1.2em">坦克大战回放查看器</b>
1081
+ <h3>键盘</h3><table>
1082
+ <tr><td><kbd>空格</kbd> / <kbd>K</kbd></td><td>播放 / 暂停</td><td><kbd>←</kbd> <kbd>→</kbd></td><td>上一回合 / 下一回合(下一回合会播动画)</td></tr>
1083
+ <tr><td><kbd>Shift</kbd>+<kbd>←</kbd> <kbd>→</kbd></td><td>后退 / 前进 10 回合</td><td><kbd>[</kbd> <kbd>]</kbd></td><td>上一个 / 下一个关键事件(击毁、基地掉血、吃星、故障)</td></tr>
1084
+ <tr><td><kbd>Home</kbd> <kbd>End</kbd></td><td>开局 / 终局</td><td><kbd>+</kbd> <kbd>-</kbd></td><td>加速 / 减速</td></tr>
1085
+ <tr><td><kbd>E</kbd></td><td>收起 / 显示结束画面</td><td><kbd>C</kbd></td><td>显示 / 隐藏坐标</td></tr>
1086
+ <tr><td><kbd>F</kbd></td><td>全屏</td><td><kbd>O</kbd> / <kbd>H</kbd></td><td>打开文件 / 本帮助</td></tr></table>
1087
+ <h3>画面</h3>
1088
+ 两方:<span class="tk s0"><i></i>0 号位(下方)</span>橙色,炮塔浅色实心,子弹圆头;<span class="tk s1"><i></i>1 号位(上方)</span>蓝色,炮塔深色空心、车身带斜纹,子弹菱形。<br>
1089
+ 车型:快速型尖头窄车身、三角炮塔;火力型圆炮塔、双炮管;装甲型宽车身、方炮塔、粗炮管。<br>
1090
+ 坦克头顶的绿格是血量;左下角的金星是等级(2 级的子弹带白环,能打掉钢墙);右下角的圆点是子弹:实心还能打,空心在飞,红色带斜杠是装填中。<br>
1091
+ 出生点上的虚影和数字是复活倒计时;补给点上的数字是离下一批星星还有几回合。<br>
1092
+ 停在某一回合时,虚线是这一回合子弹飞过的路线,虚线框是刚被打掉的墙,叉是被毁的位置,红杠是没走成的方向,车外一圈红色短线表示这一回合被打中。<br>
1093
+ 一回合分两个小步:动画的前一半是第 1 小步,后一半是第 2 小步;被毁、复活、吃星、出星星在回合末。
1094
+ <h3>地址栏参数</h3>
1095
+ 写在文件名后面,例如 <code>名字.html?turn=37&amp;p=0.5</code>(<code>#turn=37</code> 也行)。<table>
1096
+ <tr><td><code>turn=N</code></td><td>打开时停在第 N 回合结算后的画面</td></tr>
1097
+ <tr><td><code>p=0.5</code></td><td>和 turn 一起用:停在第 N 回合动画进行到一半的地方(0–1;0.5 是第 1 小步刚走完)</td></tr>
1098
+ <tr><td><code>tail=0.3</code></td><td>和 turn 一起用:停在第 N 回合结算后 0.3 回合的地方(回合末的爆炸、复活、升级特效)</td></tr>
1099
+ <tr><td><code>play=1</code></td><td>打开后自动播放(从 turn 指定的回合起)</td></tr>
1100
+ <tr><td><code>speed=4</code></td><td>播放速度,每秒几回合(0.5、1、2、4、8、16)</td></tr>
1101
+ <tr><td><code>end=0</code></td><td>到终局时不弹结束画面</td></tr>
1102
+ <tr><td><code>coords=0</code></td><td>不显示棋盘四周的坐标</td></tr>
1103
+ <tr><td><code>clean=1</code></td><td>录屏用:隐藏顶栏和时间轴图例</td></tr>
1104
+ <tr><td><code>check=1</code></td><td>自查:逐回合核对动画用的小步重算和回放数据是否一致,结果显示在顶栏</td></tr></table>
1105
+ <div style="margin-top:.8em;text-align:right"><button id="bhelpx">关闭</button></div>`;
1106
+ const toggleHelp=on=>$('help').classList.toggle('on',on);
1107
+ $('bhelp').onclick=()=>toggleHelp(true);$('bhelpx').onclick=()=>toggleHelp(false);
1108
+ $('help').addEventListener('click',e=>{if(e.target===$('help'))toggleHelp(false)});
1109
+ addEventListener('keydown',e=>{
1110
+ if(e.ctrlKey||e.metaKey||e.altKey)return;
1111
+ const tag=e.target.tagName;if(tag==='INPUT'||tag==='TEXTAREA')return;
1112
+ if($('help').classList.contains('on')){if(e.key==='Escape'||e.key==='h'||e.key==='H'||e.key==='?')toggleHelp(false);return}
1113
+ const k=e.key;let used=true;
1114
+ if(k===' '||k==='k'||k==='K')togglePlay();
1115
+ else if(k==='ArrowRight')e.shiftKey?seek(cur+10):stepFwd();
1116
+ else if(k==='ArrowLeft')e.shiftKey?seek(cur-10):seek(prog>0?cur:cur-1);
1117
+ else if(k==='Home')seek(0);else if(k==='End')seek(N);
1118
+ else if(k==='['||k===','||k==='PageUp')keyJump(-1);else if(k===']'||k==='.'||k==='PageDown')keyJump(1);
1119
+ else if(k==='+'||k==='=')setSpeed(SPEEDS[Math.min(SPEEDS.length-1,SPEEDS.indexOf(speed)+1)]);
1120
+ else if(k==='-'||k==='_')setSpeed(SPEEDS[Math.max(0,SPEEDS.indexOf(speed)-1)]);
1121
+ else if(k==='e'||k==='E'){showEnd=!showEnd;dirty=true}
1122
+ else if(k==='c'||k==='C'){showCoords=!showCoords;terrainKey='';dirty=true}
1123
+ else if(k==='f'||k==='F'){document.fullscreenElement?document.exitFullscreen():document.documentElement.requestFullscreen&&document.documentElement.requestFullscreen()}
1124
+ else if(k==='o'||k==='O')$('file').click();
1125
+ else if(k==='h'||k==='H'||k==='?')toggleHelp(true);
1126
+ else used=false;
1127
+ if(used)e.preventDefault();
1128
+ });
1129
+
1130
+ // ====================================================================
1131
+ // 启动
1132
+ // ====================================================================
1133
+ (function start(){
1134
+ setSpeed(SPEEDS.includes(argNum('speed',2))?argNum('speed',2):2);
1135
+ if(ARGS.coords==='0')showCoords=false;
1136
+ if(ARGS.clean==='1')document.body.classList.add('clean');
1137
+ if(!EMBED||!load(EMBED)){$('drop').classList.add('on');layout();requestAnimationFrame(frame);return}
1138
+ const turn=clamp(Math.round(argNum('turn',0)),0,N),p=argNum('p',0),tl=argNum('tail',-1);
1139
+ if(p>0&&p<1&&turn>=1){cur=turn-1;prog=p}else{cur=turn;prog=0;tail=tl>=0?Math.min(tl,TAIL_MAX):TAIL_MAX;holdTail=tl>=0}
1140
+ if(ARGS.end==='0')showEnd=false;
1141
+ if(ARGS.check==='1'){const bad=selfCheck(),el=document.createElement('span');el.id='selfcheck';el.className='warnchip';
1142
+ el.style.background=bad.length?'var(--bad)':'var(--good)';el.textContent=bad.length?`自查:${bad.length} 处不一致`:`自查通过(${N} 回合)`;el.title=bad.join('\n');el.dataset.detail=bad.join(' | ');$('meta').append(el)}
1143
+ layout();paint();
1144
+ const keepEnd=showEnd;
1145
+ if(ARGS.play==='1'){togglePlay(true);showEnd=keepEnd}
1146
+ requestAnimationFrame(frame);
1147
+ })();
1148
+ </script>
1149
+ </body>
1150
+ </html>