cuju-render 0.1.0__py3-none-any.whl

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 (382) hide show
  1. cuju/render/__init__.py +759 -0
  2. cuju/render/__main__.py +1065 -0
  3. cuju/render/_export/__init__.py +3 -0
  4. cuju/render/_export/feed.py +542 -0
  5. cuju/render/_export/sizes.py +157 -0
  6. cuju/render/_export/video.py +1618 -0
  7. cuju/render/_export/video_jobs.py +408 -0
  8. cuju/render/_format/__init__.py +3 -0
  9. cuju/render/_format/annotations.py +235 -0
  10. cuju/render/_format/codec.py +307 -0
  11. cuju/render/_format/keep.py +121 -0
  12. cuju/render/_format/recording.py +860 -0
  13. cuju/render/_format/writer.py +54 -0
  14. cuju/render/_guide/__init__.py +3 -0
  15. cuju/render/_guide/doctor.py +523 -0
  16. cuju/render/_guide/help.py +62 -0
  17. cuju/render/_guide/tutorial.py +254 -0
  18. cuju/render/_http/__init__.py +3 -0
  19. cuju/render/_http/commands.py +57 -0
  20. cuju/render/_http/headers.py +103 -0
  21. cuju/render/_http/launch.py +500 -0
  22. cuju/render/_http/launchers.py +408 -0
  23. cuju/render/_http/pwa.py +128 -0
  24. cuju/render/_http/server.py +2105 -0
  25. cuju/render/_http/window.py +360 -0
  26. cuju/render/_moments/__init__.py +3 -0
  27. cuju/render/_moments/captions.py +284 -0
  28. cuju/render/_moments/events.py +1123 -0
  29. cuju/render/_moments/search.py +694 -0
  30. cuju/render/_packages/__init__.py +3 -0
  31. cuju/render/_packages/glsl.py +746 -0
  32. cuju/render/_packages/loudness.py +334 -0
  33. cuju/render/_packages/pack.py +1959 -0
  34. cuju/render/_packages/presentation.py +1376 -0
  35. cuju/render/_packages/vocabulary.py +609 -0
  36. cuju/render/_playing/__init__.py +3 -0
  37. cuju/render/_playing/achievements.py +463 -0
  38. cuju/render/_playing/bench.py +609 -0
  39. cuju/render/_playing/challenges.py +1813 -0
  40. cuju/render/_playing/demo.py +323 -0
  41. cuju/render/_playing/draw.py +211 -0
  42. cuju/render/_playing/errors.py +20 -0
  43. cuju/render/_playing/game.py +645 -0
  44. cuju/render/_playing/limits.py +339 -0
  45. cuju/render/_playing/play.py +926 -0
  46. cuju/render/_playing/policy_worker.py +250 -0
  47. cuju/render/_playing/strict.py +810 -0
  48. cuju/render/_registry/__init__.py +3 -0
  49. cuju/render/_registry/clips.py +394 -0
  50. cuju/render/_registry/joints.py +108 -0
  51. cuju/render/_registry/teams.py +2058 -0
  52. cuju/render/_store/__init__.py +3 -0
  53. cuju/render/_store/home.py +70 -0
  54. cuju/render/_store/library.py +604 -0
  55. cuju/render/_store/samples.py +163 -0
  56. cuju/render/_text/__init__.py +3 -0
  57. cuju/render/_text/languages.py +637 -0
  58. cuju/render/_text/messages.py +1396 -0
  59. cuju/render/_tournaments/__init__.py +3 -0
  60. cuju/render/_tournaments/results.py +547 -0
  61. cuju/render/_tournaments/tournament.py +2742 -0
  62. cuju/render/_tournaments/tournament_cli.py +587 -0
  63. cuju/render/_tournaments/tournament_runner.py +1975 -0
  64. cuju/render/_version.py +6 -0
  65. cuju/render/_watch/__init__.py +3 -0
  66. cuju/render/_watch/grid.py +265 -0
  67. cuju/render/_watch/live.py +664 -0
  68. cuju/render/_watch/notebook.py +79 -0
  69. cuju/render/_watch/show.py +171 -0
  70. cuju/render/samples/sample-11v11.cjr +0 -0
  71. cuju/render/samples/sample-5v5.cjr +0 -0
  72. cuju/render/samples/teams/starter/main.py +33 -0
  73. cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
  74. cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
  75. cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
  76. cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
  77. cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
  78. cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
  79. cuju/render/web/assets/adboard_polygon.webp +0 -0
  80. cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
  81. cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
  82. cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
  83. cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
  84. cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
  85. cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
  86. cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
  87. cuju/render/web/assets/ball.glb +0 -0
  88. cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
  89. cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
  90. cuju/render/web/assets/challenges/corner.mp4 +0 -0
  91. cuju/render/web/assets/challenges/corner.webp +0 -0
  92. cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
  93. cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
  94. cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
  95. cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
  96. cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
  97. cuju/render/web/assets/challenges/empty_goal.webp +0 -0
  98. cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
  99. cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
  100. cuju/render/web/assets/challenges/manifest.json +375 -0
  101. cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
  102. cuju/render/web/assets/challenges/one_on_one.webp +0 -0
  103. cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
  104. cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
  105. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
  106. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
  107. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
  108. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
  109. cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
  110. cuju/render/web/assets/challenges/run_to_score.webp +0 -0
  111. cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
  112. cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
  113. cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
  114. cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
  115. cuju/render/web/assets/crowd.webp +0 -0
  116. cuju/render/web/assets/crowd_normal.webp +0 -0
  117. cuju/render/web/assets/cuju-logo-384.png +0 -0
  118. cuju/render/web/assets/cuju-logo.png +0 -0
  119. cuju/render/web/assets/cuju-logo.svg +46 -0
  120. cuju/render/web/assets/cuju-mark-light.svg +33 -0
  121. cuju/render/web/assets/cuju-mark.svg +33 -0
  122. cuju/render/web/assets/goal.glb +0 -0
  123. cuju/render/web/assets/grass.webp +0 -0
  124. cuju/render/web/assets/hair_black.png +0 -0
  125. cuju/render/web/assets/hair_blonde.png +0 -0
  126. cuju/render/web/assets/hair_brown.png +0 -0
  127. cuju/render/web/assets/hair_darkblonde.png +0 -0
  128. cuju/render/web/assets/hair_long01.glb +0 -0
  129. cuju/render/web/assets/hair_long02.glb +0 -0
  130. cuju/render/web/assets/hair_medium01.glb +0 -0
  131. cuju/render/web/assets/hair_red.png +0 -0
  132. cuju/render/web/assets/hair_short01.glb +0 -0
  133. cuju/render/web/assets/hair_short02.glb +0 -0
  134. cuju/render/web/assets/icons/cuju-192.png +0 -0
  135. cuju/render/web/assets/icons/cuju-512.png +0 -0
  136. cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
  137. cuju/render/web/assets/kit_goalkeeper.webp +0 -0
  138. cuju/render/web/assets/kit_mask.png +0 -0
  139. cuju/render/web/assets/kit_normal.webp +0 -0
  140. cuju/render/web/assets/manifest.json +601 -0
  141. cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
  142. cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
  143. cuju/render/web/assets/models/kaykit-knight.glb +0 -0
  144. cuju/render/web/assets/models/kaykit-knight.json +23 -0
  145. cuju/render/web/assets/models/kaykit-mage.glb +0 -0
  146. cuju/render/web/assets/models/kaykit-mage.json +23 -0
  147. cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
  148. cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
  149. cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
  150. cuju/render/web/assets/models/kaykit-ranger.json +23 -0
  151. cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
  152. cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
  153. cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
  154. cuju/render/web/assets/models/kaykit-rogue.json +23 -0
  155. cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
  156. cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
  157. cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
  158. cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
  159. cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
  160. cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
  161. cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
  162. cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
  163. cuju/render/web/assets/pitch.webp +0 -0
  164. cuju/render/web/assets/pitch_normal.webp +0 -0
  165. cuju/render/web/assets/player.glb +0 -0
  166. cuju/render/web/assets/shoe.webp +0 -0
  167. cuju/render/web/assets/shoe_sole.png +0 -0
  168. cuju/render/web/assets/skin_01.png +0 -0
  169. cuju/render/web/assets/skin_02.png +0 -0
  170. cuju/render/web/assets/skin_03.png +0 -0
  171. cuju/render/web/assets/skin_04.png +0 -0
  172. cuju/render/web/assets/sounds/applause.ogg +0 -0
  173. cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
  174. cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
  175. cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
  176. cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
  177. cuju/render/web/assets/sounds/city-horn.ogg +0 -0
  178. cuju/render/web/assets/sounds/city-loop.ogg +0 -0
  179. cuju/render/web/assets/sounds/city-siren.ogg +0 -0
  180. cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
  181. cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
  182. cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
  183. cuju/render/web/assets/sounds/gulls.ogg +0 -0
  184. cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
  185. cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
  186. cuju/render/web/assets/sounds/sounds.json +46 -0
  187. cuju/render/web/assets/sounds/space-beep.ogg +0 -0
  188. cuju/render/web/assets/sounds/space-hum.ogg +0 -0
  189. cuju/render/web/assets/sounds/space-radio.ogg +0 -0
  190. cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
  191. cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
  192. cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
  193. cuju/render/web/easy.css +357 -0
  194. cuju/render/web/export.html +23 -0
  195. cuju/render/web/feed.css +262 -0
  196. cuju/render/web/feed.html +28 -0
  197. cuju/render/web/flat.css +120 -0
  198. cuju/render/web/home.css +837 -0
  199. cuju/render/web/index.html +373 -0
  200. cuju/render/web/js/boot.js +50 -0
  201. cuju/render/web/js/broadcast/audio.js +2141 -0
  202. cuju/render/web/js/broadcast/celebrate.js +1238 -0
  203. cuju/render/web/js/broadcast/decisions.js +609 -0
  204. cuju/render/web/js/broadcast/hud.js +352 -0
  205. cuju/render/web/js/broadcast/show.js +3614 -0
  206. cuju/render/web/js/broadcast/transition.js +2231 -0
  207. cuju/render/web/js/broadcast/voices.js +153 -0
  208. cuju/render/web/js/capture.js +101 -0
  209. cuju/render/web/js/core/accessibility.js +90 -0
  210. cuju/render/web/js/core/api.js +429 -0
  211. cuju/render/web/js/core/comfort.js +135 -0
  212. cuju/render/web/js/core/i18n.js +705 -0
  213. cuju/render/web/js/core/icons.js +167 -0
  214. cuju/render/web/js/core/input-settings.js +156 -0
  215. cuju/render/web/js/core/keys.js +277 -0
  216. cuju/render/web/js/core/language-ui.js +125 -0
  217. cuju/render/web/js/core/menu.js +853 -0
  218. cuju/render/web/js/core/names.js +113 -0
  219. cuju/render/web/js/core/page-structure.js +110 -0
  220. cuju/render/web/js/core/scenarios.js +109 -0
  221. cuju/render/web/js/core/spoilers.js +236 -0
  222. cuju/render/web/js/core/typeahead.js +38 -0
  223. cuju/render/web/js/export/dom-picture.js +330 -0
  224. cuju/render/web/js/export/export-host.js +97 -0
  225. cuju/render/web/js/export/feed-dialog.js +191 -0
  226. cuju/render/web/js/export/feed-params.js +266 -0
  227. cuju/render/web/js/export/feed.js +363 -0
  228. cuju/render/web/js/export/mp4-mux.js +295 -0
  229. cuju/render/web/js/export/photo-core.js +313 -0
  230. cuju/render/web/js/export/photo.js +1314 -0
  231. cuju/render/web/js/export/safe-frame.js +59 -0
  232. cuju/render/web/js/export/size-fields.js +103 -0
  233. cuju/render/web/js/export/sizes.js +178 -0
  234. cuju/render/web/js/export/video-boot.js +55 -0
  235. cuju/render/web/js/export/video-browser.js +325 -0
  236. cuju/render/web/js/export/video-export.js +417 -0
  237. cuju/render/web/js/export/video-plan.js +207 -0
  238. cuju/render/web/js/export/video-range.js +185 -0
  239. cuju/render/web/js/export/webm-mux.js +198 -0
  240. cuju/render/web/js/feed-boot.js +151 -0
  241. cuju/render/web/js/feed-frame.js +101 -0
  242. cuju/render/web/js/help/doctor.js +488 -0
  243. cuju/render/web/js/help/firstrun.js +379 -0
  244. cuju/render/web/js/help/help-center.js +438 -0
  245. cuju/render/web/js/help/install-offer.js +164 -0
  246. cuju/render/web/js/help/install-state.js +104 -0
  247. cuju/render/web/js/help/lessons-core.js +207 -0
  248. cuju/render/web/js/help/lessons.js +463 -0
  249. cuju/render/web/js/help/palette.js +584 -0
  250. cuju/render/web/js/help/pwa.js +257 -0
  251. cuju/render/web/js/help/tour-core.js +241 -0
  252. cuju/render/web/js/help/tour.js +461 -0
  253. cuju/render/web/js/main.js +2513 -0
  254. cuju/render/web/js/pages/draw.js +188 -0
  255. cuju/render/web/js/pages/fieldpage.js +530 -0
  256. cuju/render/web/js/pages/home.js +521 -0
  257. cuju/render/web/js/pages/matches.js +779 -0
  258. cuju/render/web/js/pages/moments-ui.js +359 -0
  259. cuju/render/web/js/pages/queue-filters.js +63 -0
  260. cuju/render/web/js/pages/recording-groups.js +80 -0
  261. cuju/render/web/js/pages/recordings.js +1069 -0
  262. cuju/render/web/js/play/bench.js +347 -0
  263. cuju/render/web/js/play/challenges.js +1302 -0
  264. cuju/render/web/js/play/medals.js +127 -0
  265. cuju/render/web/js/play/play.js +1953 -0
  266. cuju/render/web/js/play/teasers.js +196 -0
  267. cuju/render/web/js/play/touch.js +350 -0
  268. cuju/render/web/js/play/tournament-layout.js +487 -0
  269. cuju/render/web/js/play/tournament-results.js +441 -0
  270. cuju/render/web/js/play/tournament-strict.js +560 -0
  271. cuju/render/web/js/play/tournament.js +2585 -0
  272. cuju/render/web/js/players/animate.js +972 -0
  273. cuju/render/web/js/players/clips.js +224 -0
  274. cuju/render/web/js/players/kaykit.js +162 -0
  275. cuju/render/web/js/players/kits.js +676 -0
  276. cuju/render/web/js/players/number-patch.js +82 -0
  277. cuju/render/web/js/players/players.js +2694 -0
  278. cuju/render/web/js/players/recolor.js +893 -0
  279. cuju/render/web/js/players/retarget.js +325 -0
  280. cuju/render/web/js/players/rig-numbers.js +174 -0
  281. cuju/render/web/js/presentation/animator.js +231 -0
  282. cuju/render/web/js/presentation/assign.js +85 -0
  283. cuju/render/web/js/presentation/behaviors.js +440 -0
  284. cuju/render/web/js/presentation/budget.js +209 -0
  285. cuju/render/web/js/presentation/catalog.js +1674 -0
  286. cuju/render/web/js/presentation/clocks.js +72 -0
  287. cuju/render/web/js/presentation/commands.js +301 -0
  288. cuju/render/web/js/presentation/contract.js +240 -0
  289. cuju/render/web/js/presentation/effects.js +904 -0
  290. cuju/render/web/js/presentation/flash.js +605 -0
  291. cuju/render/web/js/presentation/freeze-worklet.js +22 -0
  292. cuju/render/web/js/presentation/glsl.js +464 -0
  293. cuju/render/web/js/presentation/host.js +323 -0
  294. cuju/render/web/js/presentation/index.js +2066 -0
  295. cuju/render/web/js/presentation/loudness-worklet.js +235 -0
  296. cuju/render/web/js/presentation/loudness.js +740 -0
  297. cuju/render/web/js/presentation/moments.js +536 -0
  298. cuju/render/web/js/presentation/post.js +327 -0
  299. cuju/render/web/js/presentation/preview.js +180 -0
  300. cuju/render/web/js/presentation/protocol.js +174 -0
  301. cuju/render/web/js/presentation/reach.js +158 -0
  302. cuju/render/web/js/presentation/rigging.js +130 -0
  303. cuju/render/web/js/presentation/sandbox.js +157 -0
  304. cuju/render/web/js/presentation/shaders.js +235 -0
  305. cuju/render/web/js/presentation/stage.js +402 -0
  306. cuju/render/web/js/presentation/teamaudio.js +722 -0
  307. cuju/render/web/js/presentation/tracks.js +197 -0
  308. cuju/render/web/js/presentation/values.js +81 -0
  309. cuju/render/web/js/recording/annotation-data.js +203 -0
  310. cuju/render/web/js/recording/captions.js +491 -0
  311. cuju/render/web/js/recording/codec.js +185 -0
  312. cuju/render/web/js/recording/decode-worker.js +70 -0
  313. cuju/render/web/js/recording/decode.js +94 -0
  314. cuju/render/web/js/recording/episodes.js +46 -0
  315. cuju/render/web/js/recording/format.js +461 -0
  316. cuju/render/web/js/recording/live.js +348 -0
  317. cuju/render/web/js/recording/moments-query.js +59 -0
  318. cuju/render/web/js/recording/moments.js +149 -0
  319. cuju/render/web/js/recording/pose.js +86 -0
  320. cuju/render/web/js/recording/timeline.js +275 -0
  321. cuju/render/web/js/teams/art.js +50 -0
  322. cuju/render/web/js/teams/clip-editor.js +293 -0
  323. cuju/render/web/js/teams/editor.js +1979 -0
  324. cuju/render/web/js/teams/library.js +344 -0
  325. cuju/render/web/js/teams/painter.js +633 -0
  326. cuju/render/web/js/teams/surprise-ui.js +195 -0
  327. cuju/render/web/js/teams/surprise.js +497 -0
  328. cuju/render/web/js/teams/team-check.js +127 -0
  329. cuju/render/web/js/watch/annotations.js +270 -0
  330. cuju/render/web/js/watch/flat.js +366 -0
  331. cuju/render/web/js/watch/grid.js +436 -0
  332. cuju/render/web/js/watch/pitch2d.js +289 -0
  333. cuju/render/web/js/watch/player-tools.js +192 -0
  334. cuju/render/web/js/watch/presets.js +201 -0
  335. cuju/render/web/js/world/arenas.js +997 -0
  336. cuju/render/web/js/world/ball.js +204 -0
  337. cuju/render/web/js/world/cameras.js +982 -0
  338. cuju/render/web/js/world/crowd.js +376 -0
  339. cuju/render/web/js/world/field.js +227 -0
  340. cuju/render/web/js/world/framing.js +145 -0
  341. cuju/render/web/js/world/occlusion.js +48 -0
  342. cuju/render/web/js/world/pitch.js +703 -0
  343. cuju/render/web/js/world/scene.js +395 -0
  344. cuju/render/web/js/world/sky.js +325 -0
  345. cuju/render/web/js/world/stadium.js +1262 -0
  346. cuju/render/web/js/world/vendor-sky.js +367 -0
  347. cuju/render/web/js/world/weather.js +264 -0
  348. cuju/render/web/languages/en.json +4750 -0
  349. cuju/render/web/lessons.json +137 -0
  350. cuju/render/web/manifest.webmanifest +23 -0
  351. cuju/render/web/style.css +8399 -0
  352. cuju/render/web/sw.js +235 -0
  353. cuju/render/web/vendor/fabric/LICENSE +16 -0
  354. cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
  355. cuju/render/web/vendor/lucide/LICENSE +43 -0
  356. cuju/render/web/vendor/lucide/icons.js +141 -0
  357. cuju/render/web/vendor/three/LICENSE +21 -0
  358. cuju/render/web/vendor/three/build/three.core.js +60586 -0
  359. cuju/render/web/vendor/three/build/three.module.js +19719 -0
  360. cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
  361. cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
  362. cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
  363. cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
  364. cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
  365. cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
  366. cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
  367. cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
  368. cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
  369. cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
  370. cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
  371. cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
  372. cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
  373. cuju_render-0.1.0.dist-info/METADATA +116 -0
  374. cuju_render-0.1.0.dist-info/RECORD +382 -0
  375. cuju_render-0.1.0.dist-info/WHEEL +4 -0
  376. cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
  377. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
  378. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
  379. cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
  380. cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
  381. cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
  382. cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,1953 @@
1
+ // Copyright 2026 The cuju Authors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // The Play a game page, where a person controls one player of a side with the keyboard or a
5
+ // controller, Xbox, Nintendo or PlayStation. GRF's built-in AI or a team of the registry plays the
6
+ // other side, and either one plays the person's teammates. The server plays the game in a process
7
+ // of its own at ten steps a second and streams each frame at once, and the page sends the held
8
+ // keys and buttons of each person's side on every change. The keys and buttons follow Gameplay
9
+ // Football's layout, and the server turns them into GRF's 19 actions with GRF's own controller logic.
10
+
11
+ import * as THREE from 'three';
12
+ import { api } from '../core/api.js';
13
+ import { serverText, t, tm, tn, tOr } from '../core/i18n.js';
14
+ import { chord, chordName } from '../core/keys.js';
15
+ import { LiveSource } from '../recording/live.js';
16
+ import { el as h } from '../core/menu.js';
17
+ import { icon, symbolOnly } from '../core/icons.js';
18
+ import { PLAYER_FLOATS } from '../recording/format.js';
19
+ import { SLOW_FPS, offersView2d } from '../watch/flat.js';
20
+ import { summaryTeams, teamName } from '../core/names.js';
21
+ import { listedScenarios, scenarioName, scenarioText } from '../core/scenarios.js';
22
+ import {
23
+ BINDINGS, DEAD_ZONE, actionName, devicesClash, keyMap, keyProblem,
24
+ loadSettings, defaultSettings, saveSettings, stickDirections,
25
+ } from '../core/input-settings.js';
26
+
27
+ const DEFAULT_SCENARIO = '11_vs_11_kaggle';
28
+ // The view keeps this many frames behind the newest one, enough to blend between two frames.
29
+ const PLAY_DELAY_FRAMES = 1.1;
30
+ // The page repeats its input this often, so the server knows it is still there.
31
+ const HEARTBEAT_MS = 500;
32
+ // A stick counts as pushed past half way along an axis, as GRF's gamepad player reads it.
33
+ const STICK = 0.5;
34
+ // The settings of the viewer that would hold play back, off while a game shows.
35
+ const HELD_BACK = { celebrations: false, replays: false, transitions: false, decisionReplays: false, podium: false, lineups: false };
36
+ // The steps in a row without an answer in time after which a registered team counts as falling
37
+ // behind, as the game reports it.
38
+ const LATE_STREAK = 10;
39
+ // The picture of a game is measured over windows of this many seconds, and a window below the
40
+ // rate of flat.js offers a lower quality once for each visit, as Watch offers the 2D view.
41
+ const SLOW_WINDOW = 5;
42
+ const SLOW_DECLINED = 'cuju.playSlowDeclined';
43
+ // The direction that GRF's sticky actions hold, by the held directions of the page.
44
+ const STICKY_NAMES = { up: 'top', down: 'bottom', left: 'left', right: 'right' };
45
+
46
+ // An error of the server as a sentence on a card, with its first letter in capitals.
47
+ function sentence(text) {
48
+ const value = String(text);
49
+ return value.charAt(0).toUpperCase() + value.slice(1);
50
+ }
51
+
52
+ // The keyboard's keys come from the input settings, by the physical key, so every layout plays the
53
+ // same. Controller actions bind to the position of a button, GRF's own Xbox layout, so the same hands
54
+ // play the same on every style of controller. South is the lower face button, east the right one,
55
+ // west the left one and north the upper one.
56
+ const PAD_BUTTONS = {
57
+ south: ['short_pass'], east: ['shot'], west: ['high_pass', 'sliding'], north: ['long_pass'],
58
+ rt: ['sprint'], rb: ['dribble'], up: ['up'], down: ['down'], left: ['left'], right: ['right'],
59
+ };
60
+ const POSITIONS = ['south', 'east', 'west', 'north', 'lb', 'rb', 'lt', 'rt', 'back', 'start', 'up', 'down', 'left', 'right'];
61
+ // The standard mapping of the Gamepad API, a button index for each position and the axes of the
62
+ // left stick. A source is a button, b, or an axis, a, with its sign, s, and threshold, t.
63
+ const STANDARD = {
64
+ south: { b: 0 }, east: { b: 1 }, west: { b: 2 }, north: { b: 3 }, lb: { b: 4 }, rb: { b: 5 },
65
+ lt: { b: 6 }, rt: { b: 7 }, back: { b: 8 }, start: { b: 9 },
66
+ up: { b: 12 }, down: { b: 13 }, left: { b: 14 }, right: { b: 15 },
67
+ stickX: { a: 0, s: 1, t: STICK }, stickY: { a: 1, s: 1, t: STICK },
68
+ };
69
+ // A hat axis gives the eight directions of a D-pad as values from -1 to 1 in steps of two
70
+ // sevenths, up first and then clockwise. A direction counts as held at its own value and at the
71
+ // two diagonals beside it. A value off that ring counts only when it matches the stored one.
72
+ const HAT_STEP = 2 / 7;
73
+ function hatIndex(value) {
74
+ const k = Math.round((value + 1) / HAT_STEP);
75
+ return k >= 0 && k <= 7 && Math.abs(value - (-1 + k * HAT_STEP)) < 0.1 ? k : -1;
76
+ }
77
+ function hatOn(value, stored) {
78
+ if (value === undefined || Math.abs(value) > 1.05) {
79
+ return false;
80
+ }
81
+ const k = hatIndex(value);
82
+ const r = hatIndex(stored);
83
+ if (k < 0 || r < 0) {
84
+ return Math.abs(value - stored) < 0.1;
85
+ }
86
+ const d = Math.abs(k - r) % 8;
87
+ return d === 0 || d === 1 || d === 7;
88
+ }
89
+
90
+ // A trigger or button that reports as an axis counts as pressed past this value.
91
+ const AXIS_ON = 0.5;
92
+ // The setup asks for each position in this order, the buttons, the left stick and the D-pad.
93
+ const SETUP_STEPS = ['south', 'east', 'west', 'north', 'lb', 'rb', 'lt', 'rt', 'back', 'start',
94
+ 'stickX', 'stickY', 'up', 'down', 'left', 'right'];
95
+ // The layouts set up by hand, one for each controller id.
96
+ const LAYOUTS_KEY = 'cuju.padLayouts';
97
+ const DIRECTIONS = new Set(['left', 'right', 'up', 'down']);
98
+ // A controller works the menus of the page by the positions of its buttons, as consoles do. A held
99
+ // direction moves once, then again after the delay and at the repeat rate while it stays held. A
100
+ // stick direction holds until the stick falls back under the release value, so one push is one move.
101
+ const NAV_DELAY_MS = 400;
102
+ const NAV_REPEAT_MS = 120;
103
+ const NAV_RELEASE = 0.3;
104
+ const FOCUSABLE = 'button, select, input, summary, a[href], [tabindex]:not([tabindex="-1"])';
105
+ // The input tester refreshes this often while it shows.
106
+ const TESTER_MS = 100;
107
+ // The names of the devices, which the status line shows and the page compares.
108
+ const KEYBOARD = t('play.device.keyboard');
109
+ const CONTROLLER = t('play.device.controller');
110
+
111
+ // The label of a binding of the input settings, by its id.
112
+ function bindingLabel(binding) {
113
+ return t(`play.binding.${binding.id}`);
114
+ }
115
+
116
+ // The name of an input held, such as short pass, as the tester lists it.
117
+ function inputName(name) {
118
+ return tOr(`play.input.${name}`, name.replaceAll('_', ' '));
119
+ }
120
+
121
+ // The name of one of GRF's engine actions, such as top left, by its index.
122
+ function engineAction(index) {
123
+ const name = actionName(index);
124
+ return tOr(`play.engineAction.${name.toLowerCase().replace(/[^a-z]+/g, '_')}`, name);
125
+ }
126
+
127
+ // Two nodes joined, such as A and B, and more joined in turn, as one fragment.
128
+ function joinAnd(nodes) {
129
+ return nodes.reduce((left, right) => tm('play.and', { left, right }));
130
+ }
131
+
132
+ // Focus that a controller moves shows the ring of keyboard focus.
133
+ function padFocus(el) {
134
+ el.focus({ focusVisible: true });
135
+ }
136
+
137
+ // The keyboard rows of the current keys, each key by its code or name, which the keyboard kit
138
+ // draws. The four arrows share a row while they move.
139
+ export function keyboardControls(settings) {
140
+ const arrows = ['left', 'up', 'down', 'right'].every((id) => settings.keys[id] === `Arrow${id[0].toUpperCase()}${id.slice(1)}`);
141
+ const rows = arrows ? [['Arrow keys', t('play.controls.move')]] : [];
142
+ for (const binding of BINDINGS) {
143
+ if (!(arrows && binding.names.length === 1 && ['left', 'right', 'up', 'down'].includes(binding.id))) {
144
+ rows.push([settings.keys[binding.id], bindingLabel(binding)]);
145
+ }
146
+ }
147
+ return rows.concat([['Esc', t('play.controls.pause')], ['H or ?', t('play.controls.help')]]);
148
+ }
149
+ // The controller rows name positions, drawn in the icons of the controller's style.
150
+ export const PAD_CONTROLS = [
151
+ ['stick or dpad', t('play.controls.move')],
152
+ ['south', t('play.binding.short_pass')],
153
+ ['east', t('play.binding.shot')],
154
+ ['west', t('play.binding.high_pass')],
155
+ ['north', t('play.binding.long_pass')],
156
+ ['rt', t('play.binding.sprint')],
157
+ ['rb', t('play.binding.dribble')],
158
+ ['start', t('play.controls.pause')],
159
+ ['back', t('play.controls.card')],
160
+ ];
161
+
162
+ // The buttons of each style of controller by position, each with its classes, spoken name, text
163
+ // on the icon and short name, drawn without images so they stay crisp at any size. A button named
164
+ // by its letters is spoken as the letters with the word button.
165
+ const padButton = (name) => t('play.pad.button', { name });
166
+ const PAD_STYLES = {
167
+ xbox: {
168
+ name: 'Xbox',
169
+ south: ['pad-face pad-a', padButton('A'), 'A', 'A'],
170
+ east: ['pad-face pad-b', padButton('B'), 'B', 'B'],
171
+ west: ['pad-face pad-x', padButton('X'), 'X', 'X'],
172
+ north: ['pad-face pad-y', padButton('Y'), 'Y', 'Y'],
173
+ lb: ['pad-shoulder', t('play.pad.xbox.lb'), 'LB', 'LB'],
174
+ rb: ['pad-shoulder', t('play.pad.xbox.rb'), 'RB', 'RB'],
175
+ lt: ['pad-trigger', t('play.pad.xbox.lt'), 'LT', 'LT'],
176
+ rt: ['pad-trigger', t('play.pad.xbox.rt'), 'RT', 'RT'],
177
+ back: ['pad-view', t('play.pad.xbox.back'), '', t('play.pad.xbox.back.short')],
178
+ start: ['pad-menu', t('play.pad.xbox.start'), '', t('play.pad.xbox.start.short')],
179
+ },
180
+ nintendo: {
181
+ name: 'Nintendo',
182
+ south: ['pad-face', padButton('B'), 'B', 'B'],
183
+ east: ['pad-face', padButton('A'), 'A', 'A'],
184
+ west: ['pad-face', padButton('Y'), 'Y', 'Y'],
185
+ north: ['pad-face', padButton('X'), 'X', 'X'],
186
+ lb: ['pad-shoulder', padButton('L'), 'L', 'L'],
187
+ rb: ['pad-shoulder', padButton('R'), 'R', 'R'],
188
+ lt: ['pad-trigger', padButton('ZL'), 'ZL', 'ZL'],
189
+ rt: ['pad-trigger', padButton('ZR'), 'ZR', 'ZR'],
190
+ back: ['pad-sys', t('play.pad.nintendo.back'), '−', t('play.pad.nintendo.back.short')],
191
+ start: ['pad-sys', t('play.pad.nintendo.start'), '+', t('play.pad.nintendo.start.short')],
192
+ },
193
+ playstation: {
194
+ name: 'PlayStation',
195
+ south: ['pad-face pad-ps pad-cross', t('play.pad.playstation.south'), '✕', t('play.pad.playstation.south.short')],
196
+ east: ['pad-face pad-ps pad-circle', t('play.pad.playstation.east'), '○', t('play.pad.playstation.east.short')],
197
+ west: ['pad-face pad-ps pad-square', t('play.pad.playstation.west'), '□', t('play.pad.playstation.west.short')],
198
+ north: ['pad-face pad-ps pad-triangle', t('play.pad.playstation.north'), '△', t('play.pad.playstation.north.short')],
199
+ lb: ['pad-shoulder', padButton('L1'), 'L1', 'L1'],
200
+ rb: ['pad-shoulder', padButton('R1'), 'R1', 'R1'],
201
+ lt: ['pad-trigger', padButton('L2'), 'L2', 'L2'],
202
+ rt: ['pad-trigger', padButton('R2'), 'R2', 'R2'],
203
+ back: ['pad-sys', t('play.pad.playstation.back'), '', t('play.pad.playstation.back.short')],
204
+ start: ['pad-menu', t('play.pad.playstation.start'), '', t('play.pad.playstation.start.short')],
205
+ },
206
+ };
207
+ const PAD_COMMON = {
208
+ stick: ['pad-stick', t('play.pad.stick'), 'L', t('play.pad.stick.short')],
209
+ dpad: ['pad-dpad', t('play.pad.dpad'), '', t('play.pad.dpad.short')],
210
+ };
211
+
212
+ function padInfo(style, pos) {
213
+ return PAD_COMMON[pos] || (PAD_STYLES[style] || PAD_STYLES.xbox)[pos];
214
+ }
215
+
216
+ export function padIcon(style, pos) {
217
+ const [kind, label, text] = padInfo(style, pos);
218
+ return h('span', { class: `padicon ${kind}`, role: 'img', 'aria-label': label, title: label }, text);
219
+ }
220
+
221
+ // The likely style of a controller from the id the browser reports, Xbox when nothing says.
222
+ function detectStyle(id) {
223
+ if (/xbox|xinput|microsoft|045e/i.test(id)) {
224
+ return 'xbox';
225
+ }
226
+ if (/dualsense|dualshock|playstation|wireless controller|054c/i.test(id)) {
227
+ return 'playstation';
228
+ }
229
+ if (/pro controller|joy-con|nintendo|057e/i.test(id)) {
230
+ return 'nintendo';
231
+ }
232
+ return 'xbox';
233
+ }
234
+
235
+ function loadLayouts() {
236
+ try {
237
+ return JSON.parse(localStorage.getItem(LAYOUTS_KEY) || '{}') || {};
238
+ } catch {
239
+ return {};
240
+ }
241
+ }
242
+
243
+ function saveLayout(id, entry) {
244
+ const all = loadLayouts();
245
+ all[id] = entry;
246
+ try {
247
+ localStorage.setItem(LAYOUTS_KEY, JSON.stringify(all));
248
+ } catch {
249
+ // The layout holds for this visit.
250
+ }
251
+ }
252
+
253
+ // The keys of a row as keycaps of the keyboard kit or controller icons, alternatives joined by or.
254
+ function controlKeys(key, style) {
255
+ if (key === 'Arrow keys') {
256
+ return h('span', { class: 'keys' }, ['Left', 'Up', 'Down', 'Right'].map((name) => chord(name)));
257
+ }
258
+ const parts = key.split(' or ');
259
+ return h('span', { class: 'keys' }, parts.flatMap((part, i) => {
260
+ const info = style && padInfo(style, part);
261
+ return [
262
+ i ? h('span', { class: 'keys-or' }, t('play.controls.or')) : null,
263
+ info ? padIcon(style, part) : chord(part),
264
+ info && !info[2] && (part === 'back' || part === 'start') ? h('span', { class: 'pad-name' }, info[3]) : null,
265
+ ];
266
+ }).filter(Boolean));
267
+ }
268
+
269
+ // A table of controls, the keyboard's or, given a style, the controller's.
270
+ function controlsTable(rows, style = null) {
271
+ return h('table', { class: 'controls-table' },
272
+ h('tbody', {}, rows.map(([key, what]) => h('tr', {}, h('th', {}, controlKeys(key, style)), h('td', {}, what)))));
273
+ }
274
+
275
+ // The controller line of the Help page in a style's icons.
276
+ function padHelp(style) {
277
+ const icons = {};
278
+ for (const pos of ['stick', 'dpad', 'south', 'east', 'west', 'north', 'rt', 'rb', 'start', 'back']) {
279
+ icons[pos] = padIcon(style, pos);
280
+ }
281
+ return tn('play.help.controller', icons);
282
+ }
283
+
284
+ // A small sketch of a controller with one position lit, the face buttons in the style's labels.
285
+ function padSketch(style, on) {
286
+ const ps = style === 'playstation';
287
+ const [sx, sy] = ps ? [80, 86] : [58, 58];
288
+ const [dx, dy] = ps ? [58, 58] : [80, 86];
289
+ const lit = (pos) => (pos === on || (pos === 'stick' && (on === 'stickX' || on === 'stickY')) ? 'part on' : 'part');
290
+ const text = (x, y, t, size = 9) => `<text x="${x}" y="${y}" font-size="${size}" text-anchor="middle" dominant-baseline="central">${t}</text>`;
291
+ const info = (pos) => padInfo(style, pos)[2];
292
+ const face = [['north', 146, 44], ['south', 146, 80], ['west', 128, 62], ['east', 164, 62]];
293
+ const pad = [['up', dx - 5, dy - 16, 10, 11], ['down', dx - 5, dy + 5, 10, 11], ['left', dx - 16, dy - 5, 11, 10], ['right', dx + 5, dy - 5, 11, 10]];
294
+ return `<svg class="pad-sketch" viewBox="0 0 200 124" aria-hidden="true">
295
+ <rect class="${lit('lt')}" x="26" y="2" width="40" height="13" rx="5"/>${text(46, 9, info('lt'), 7)}
296
+ <rect class="${lit('rt')}" x="134" y="2" width="40" height="13" rx="5"/>${text(154, 9, info('rt'), 7)}
297
+ <rect class="${lit('lb')}" x="22" y="17" width="48" height="9" rx="4"/>${text(46, 21.5, info('lb'), 7)}
298
+ <rect class="${lit('rb')}" x="130" y="17" width="48" height="9" rx="4"/>${text(154, 21.5, info('rb'), 7)}
299
+ <rect class="body" x="10" y="28" width="180" height="92" rx="44"/>
300
+ <circle class="${lit('stick')}" cx="${sx}" cy="${sy}" r="12"/>
301
+ ${pad.map(([pos, x, y, w, hh]) => `<rect class="${lit(pos)}" x="${x}" y="${y}" width="${w}" height="${hh}" rx="2"/>`).join('')}
302
+ <circle class="${lit('back')}" cx="88" cy="48" r="5"/><circle class="${lit('start')}" cx="112" cy="48" r="5"/>
303
+ ${face.map(([pos, x, y]) => `<circle class="${lit(pos)}" cx="${x}" cy="${y}" r="9"/>${text(x, y, info(pos))}`).join('')}
304
+ </svg>`;
305
+ }
306
+
307
+ // One input device, its held inputs and the buttons pressed since the last send.
308
+ class Device {
309
+ constructor(id, label) {
310
+ this.id = id;
311
+ this.label = label;
312
+ this.held = new Set();
313
+ this.pressed = new Set();
314
+ this.usedAt = 0;
315
+ }
316
+
317
+ set(names, down) {
318
+ for (const name of names) {
319
+ if (down && !this.held.has(name) && !DIRECTIONS.has(name)) {
320
+ this.pressed.add(name);
321
+ }
322
+ if (down) {
323
+ this.held.add(name);
324
+ } else {
325
+ this.held.delete(name);
326
+ }
327
+ }
328
+ this.usedAt = performance.now();
329
+ }
330
+ }
331
+
332
+ export class PlayPage {
333
+ constructor(app) {
334
+ this.app = app;
335
+ this.root = document.getElementById('page-play');
336
+ this.setup = { scenario: DEFAULT_SCENARIO, side: 'left', ability: [null, null], record: true, teams: ['builtin', 'builtin'] };
337
+ this.teamList = [];
338
+ this.game = null;
339
+ this.keyboard = new Device('keyboard', KEYBOARD);
340
+ this.input = loadSettings();
341
+ this.keys = keyMap(this.input);
342
+ this.testerAt = 0;
343
+ this.pads = new Map();
344
+ this.built = false;
345
+ this.saved = null;
346
+ window.addEventListener('keydown', (e) => this.onKey(e, true), true);
347
+ window.addEventListener('keyup', (e) => this.onKey(e, false), true);
348
+ window.addEventListener('blur', () => this.releaseKeys());
349
+ document.addEventListener('visibilitychange', () => {
350
+ if (document.hidden && this.playing) {
351
+ this.pause(true);
352
+ }
353
+ });
354
+ window.addEventListener('pagehide', () => {
355
+ if (this.game && !this.game.ended) {
356
+ fetch(`/api/game/${encodeURIComponent(this.game.id)}/pause`, {
357
+ method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: '{"paused": true}',
358
+ }).catch(() => {});
359
+ }
360
+ });
361
+ const loop = () => {
362
+ requestAnimationFrame(loop);
363
+ this.pollPads();
364
+ };
365
+ requestAnimationFrame(loop);
366
+ setInterval(() => this.heartbeat(), HEARTBEAT_MS);
367
+ this.marker = this.makeMarker();
368
+ app.poseHooks.push((pose, s) => this.placeMarker(pose, s));
369
+ }
370
+
371
+ get visible() {
372
+ return !this.root.hidden;
373
+ }
374
+
375
+ get playing() {
376
+ return !!(this.game && !this.game.ended && !this.game.paused && this.game.started);
377
+ }
378
+
379
+ // The page.
380
+
381
+ build() {
382
+ this.built = true;
383
+ this.scenarioSelect = h('select', { 'aria-label': t('play.setup.scenario') });
384
+ this.scenarioSelect.addEventListener('change', () => {
385
+ this.setup.scenario = this.scenarioSelect.value;
386
+ this.scenarioText.textContent = scenarioText(this.setup.scenario);
387
+ });
388
+ this.scenarioText = h('p', { class: 'scenario-text muted small', 'aria-live': 'polite' });
389
+ const sides = ['left', 'right', 'both'].map((v) => [v, t(`play.setup.side.${v}`)]);
390
+ this.sideSelect = h('select', { 'aria-label': t('play.setup.side') }, sides.map(([v, text]) => h('option', { value: v }, text)));
391
+ this.sideSelect.addEventListener('change', () => {
392
+ this.setup.side = this.sideSelect.value;
393
+ this.syncTeams();
394
+ });
395
+ // The team of each side, which plays a side without a person and joins a person as teammates.
396
+ this.teamLabels = [0, 1].map(() => h('span', {}));
397
+ this.teamSelects = [0, 1].map((side) => {
398
+ const select = h('select', {});
399
+ select.addEventListener('change', () => {
400
+ this.setup.teams[side] = select.value;
401
+ });
402
+ return select;
403
+ });
404
+ const ability = (side) => {
405
+ const where = side ? 'right' : 'left';
406
+ const own = h('input', { type: 'checkbox', 'aria-label': t(`play.setup.ability.${where}.own`) });
407
+ const range = h('input', { type: 'range', min: '0', max: '1', step: '0.05', value: '0.5', disabled: true, 'aria-label': t(`play.setup.ability.${where}.range`) });
408
+ const shown = h('output', { class: 'ability-value' }, t('play.setup.ability.scenario'));
409
+ const sync = () => {
410
+ this.setup.ability[side] = own.checked ? Number(range.value) : null;
411
+ range.disabled = !own.checked;
412
+ shown.textContent = own.checked ? Number(range.value).toFixed(2) : t('play.setup.ability.scenario');
413
+ };
414
+ own.addEventListener('change', sync);
415
+ range.addEventListener('input', sync);
416
+ return h('div', { class: 'ability' }, h('label', { class: 'check' }, own, t(`play.setup.ability.${where}`)), range, shown);
417
+ };
418
+ this.recordBox = h('input', { type: 'checkbox', checked: true });
419
+ this.recordBox.addEventListener('change', () => {
420
+ this.setup.record = this.recordBox.checked;
421
+ });
422
+ this.startButton = h('button', { type: 'button', class: 'btn primary', onclick: () => this.start() }, icon('play'), t('play.setup.start'));
423
+ this.padNote = h('div', { class: 'muted small play-pads' });
424
+ this.padTable = h('div', {}, h('h3', {}, CONTROLLER), controlsTable(PAD_CONTROLS, 'xbox'));
425
+ this.setupView = h('section', { class: 'play-setup', 'aria-labelledby': 'play-title' },
426
+ h('header', {}, h('h2', { id: 'play-title' }, t('play.setup.title')),
427
+ h('p', { class: 'muted' }, t('play.setup.lead'))),
428
+ h('div', { class: 'setup-row' },
429
+ h('label', { class: 'field' }, t('play.setup.scenario'), this.scenarioSelect),
430
+ h('label', { class: 'field' }, t('play.setup.side'), this.sideSelect)),
431
+ this.scenarioText,
432
+ h('div', { class: 'setup-row' }, [0, 1].map((side) => h('label', { class: 'field' }, this.teamLabels[side], this.teamSelects[side]))),
433
+ h('div', { class: 'setup-row', title: t('play.setup.ability.title') }, ability(0), ability(1)),
434
+ h('label', { class: 'check' }, this.recordBox, t('play.setup.record')),
435
+ h('div', { class: 'play-tables' },
436
+ this.keyTable = h('div', {}, h('h3', {}, KEYBOARD), controlsTable(keyboardControls(this.input))),
437
+ this.padTable),
438
+ this.padNote,
439
+ this.inputSettings(),
440
+ h('div', { class: 'play-row' }, this.startButton));
441
+ this.view = h('div', { class: 'play-view', tabindex: '0', 'aria-label': t('play.view') });
442
+ this.view.addEventListener('focusout', () => this.releaseKeys());
443
+ this.status = h('div', { class: 'play-status', 'aria-live': 'polite' });
444
+ this.card = h('div', { class: 'play-card', hidden: true });
445
+ this.resumeButton = h('button', { type: 'button', class: 'btn primary', onclick: () => this.pause(false) }, icon('play'), t('play.pause.resume'));
446
+ this.controlsButton = h('button', { type: 'button', class: 'btn', onclick: () => this.openCard() }, icon('keyboard'), t('play.pause.controls'));
447
+ this.menu = h('div', { class: 'play-menu', role: 'dialog', 'aria-label': t('play.pause.title'), hidden: true },
448
+ h('div', { class: 'play-panel' }, h('h2', {}, t('play.pause.title')),
449
+ this.resumeButton,
450
+ this.controlsButton,
451
+ h('button', { type: 'button', class: 'btn', onclick: () => this.openPadSetup() }, icon('gamepad'), t('play.padSetup.open')),
452
+ h('button', { type: 'button', class: 'btn danger', onclick: () => this.quit() }, icon('exit'), t('play.pause.quit')),
453
+ h('button', { type: 'button', class: 'btn danger', onclick: () => this.goHome(true) }, icon('exit'), t('play.pause.quitHome')),
454
+ h('button', { type: 'button', class: 'btn', onclick: () => this.goHome() }, icon('home'), t('play.home')),
455
+ this.menuHint = h('p', { class: 'muted small keys pad-hint', hidden: true })));
456
+ this.endPanel = h('div', { class: 'play-panel' });
457
+ this.end = h('div', { class: 'play-menu', role: 'dialog', 'aria-label': t('play.end.fullTime'), hidden: true }, this.endPanel);
458
+ this.end.addEventListener('keydown', (e) => {
459
+ if (e.key === 'Escape') {
460
+ e.preventDefault();
461
+ this.closeEnd();
462
+ }
463
+ });
464
+ this.wait = h('div', { class: 'play-wait', hidden: true }, h('div', { class: 'spinner' }), h('span', {}, t('play.starting')));
465
+ // The card follows the pause menu, so the card that Controls opens is the dialog on top.
466
+ this.stage = h('div', { class: 'play-stage', hidden: true }, this.view, this.status, this.wait, this.menu, this.card, this.end);
467
+ // The offer names the controller that reports its own layout, which its button sets up.
468
+ this.offerPad = null;
469
+ this.offer = h('div', { class: 'pad-offer', role: 'status', hidden: true },
470
+ h('span', {}, tn('play.padOffer.text')),
471
+ h('button', { type: 'button', class: 'btn primary', onclick: () => this.openPadSetup(this.offerPad) }, icon('gamepad'), t('play.padSetup.open')),
472
+ h('button', { type: 'button', class: 'btn', ...symbolOnly(t('play.padOffer.later')), onclick: () => { this.offer.hidden = true; } }, icon('close')));
473
+ this.slowText = h('span', {});
474
+ this.slowOffer = h('div', { class: 'pad-offer play-slow-offer', role: 'status', hidden: true },
475
+ this.slowText,
476
+ h('button', { type: 'button', class: 'btn primary', onclick: () => {
477
+ this.slowOffer.hidden = true;
478
+ this.app.setQuality('low');
479
+ this.app.hud.toast(t('play.slow.done'), 'info', 4000);
480
+ } }, t('play.slow.low')),
481
+ h('button', { type: 'button', class: 'btn', onclick: () => {
482
+ this.slowOffer.hidden = true;
483
+ try {
484
+ localStorage.setItem(SLOW_DECLINED, 'true');
485
+ } catch {
486
+ // Storage can be unavailable, the offer then stays away for this visit only.
487
+ }
488
+ } }, t('play.slow.keep')));
489
+ this.setupBox = h('div', { class: 'play-panel pad-setup-panel' });
490
+ this.setupPanel = h('div', { class: 'pad-setup', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('play.padSetup.open'), hidden: true }, this.setupBox);
491
+ this.stage.append(this.slowOffer);
492
+ this.root.replaceChildren(this.setupView, this.stage, this.offer, this.setupPanel);
493
+ this.shownStyle = null;
494
+ this.refreshStyle();
495
+ }
496
+
497
+ async show() {
498
+ if (!this.built) {
499
+ this.build();
500
+ }
501
+ try {
502
+ const scenarios = listedScenarios(await api.scenarios());
503
+ this.scenarioSelect.replaceChildren(...scenarios.map((s) => h('option', { value: s }, scenarioName(s))));
504
+ this.scenarioSelect.value = scenarios.includes(this.setup.scenario) ? this.setup.scenario : scenarios[0];
505
+ this.setup.scenario = this.scenarioSelect.value;
506
+ this.scenarioText.textContent = scenarioText(this.setup.scenario);
507
+ this.teamList = await api.teams();
508
+ } catch {
509
+ // The server is away, the start says so.
510
+ }
511
+ this.syncTeams();
512
+ this.updatePadNote();
513
+ if (this.game) {
514
+ this.enterStage();
515
+ // A game that the page started away from Play, such as a match of a tournament, has
516
+ // no stream yet, and a game whose picture another source took connects again.
517
+ const game = this.game;
518
+ if (!game.ended && (game.source ? this.app.source !== game.source : !game.live)) {
519
+ this.connect();
520
+ }
521
+ }
522
+ }
523
+
524
+ hide() {
525
+ if (this.game && !this.game.ended && this.game.started && !this.game.paused) {
526
+ this.pause(true);
527
+ }
528
+ this.leaveStage();
529
+ }
530
+
531
+ // The stage borrows the viewer's picture and its scoreboard while it shows.
532
+ enterStage() {
533
+ this.setupView.hidden = true;
534
+ this.stage.hidden = false;
535
+ this.app.view.attach(this.view);
536
+ this.updateStatus();
537
+ const hud = document.getElementById('hud');
538
+ if (hud.parentNode !== this.stage) {
539
+ this.stage.insertBefore(hud, this.status);
540
+ }
541
+ if (!this.saved) {
542
+ this.saved = this.app.settings;
543
+ this.app.settings = { ...this.saved, ...HELD_BACK };
544
+ }
545
+ this.app.cameras.setMode('broadcast');
546
+ }
547
+
548
+ leaveStage() {
549
+ if (!this.built) {
550
+ return;
551
+ }
552
+ const hud = document.getElementById('hud');
553
+ if (hud.parentNode === this.stage) {
554
+ document.getElementById('watch').insertBefore(hud, document.getElementById('stinger'));
555
+ }
556
+ this.app.view.attach(document.getElementById('stage'));
557
+ if (this.saved) {
558
+ // Changes made in the settings panel meanwhile stay, the held back ones return.
559
+ const now = this.app.settings;
560
+ this.app.settings = this.saved;
561
+ for (const key of Object.keys(now)) {
562
+ if (!(key in HELD_BACK)) {
563
+ this.saved[key] = now[key];
564
+ }
565
+ }
566
+ this.saved = null;
567
+ }
568
+ this.marker.visible = false;
569
+ }
570
+
571
+ // The team lists of both sides. A side without a person lists every team, and a person's side
572
+ // lists the teams of dynamic assignment, which share a side with a person as GRF's agents do.
573
+ syncTeams() {
574
+ if (!this.built) {
575
+ return;
576
+ }
577
+ const humans = [this.setup.side !== 'right', this.setup.side !== 'left'];
578
+ const two = humans[0] && humans[1];
579
+ [0, 1].forEach((side) => {
580
+ const human = humans[side];
581
+ const role = human ? (two ? t('play.team.role.player', { number: side + 1 }) : t('play.team.role.yours')) : t('play.team.role.opponents');
582
+ const where = side ? 'right' : 'left';
583
+ this.teamLabels[side].textContent = t(`play.team.${where}`, { role });
584
+ const teams = this.teamList.filter((team) => team.kind !== 'builtin'
585
+ && (!human || (team.control && team.control.assignment === 'dynamic')));
586
+ const select = this.teamSelects[side];
587
+ select.setAttribute('aria-label', t(`play.team.${where}.select`, { role }));
588
+ select.replaceChildren(h('option', { value: 'builtin' }, t('play.team.builtin')),
589
+ ...teams.map((team) => h('option', { value: team.id }, t(team.format === '5v5' ? 'play.team.five' : 'play.team.eleven', { name: teamName(team) }))));
590
+ select.value = teams.some((team) => team.id === this.setup.teams[side]) ? this.setup.teams[side] : 'builtin';
591
+ this.setup.teams[side] = select.value;
592
+ });
593
+ }
594
+
595
+ showSetup(focus = true) {
596
+ this.leaveStage();
597
+ this.game = null;
598
+ this.setupView.hidden = false;
599
+ this.stage.hidden = true;
600
+ if (focus) {
601
+ this.startButton.focus();
602
+ }
603
+ }
604
+
605
+ // Leaves the end card, for the setup, or for the Challenges page after an attempt of a
606
+ // challenge, with the focus on that challenge's card.
607
+ closeEnd() {
608
+ const spec = this.game && this.game.spec;
609
+ if (spec && spec.challenge && this.app.challengesPage) {
610
+ this.app.challengesPage.back(spec.challenge.id);
611
+ return;
612
+ }
613
+ this.showSetup();
614
+ }
615
+
616
+ // A game.
617
+
618
+ // A spec in place of the setup starts a game of a challenge, with its own seed.
619
+ async start(spec = null) {
620
+ const s = spec || this.setup;
621
+ const humans = [s.side !== 'right', s.side !== 'left'];
622
+ this.startButton.disabled = true;
623
+ let entry;
624
+ try {
625
+ entry = await api.startGame({ scenario: s.scenario, humans, ability: s.ability.slice(), record: s.record, teams: s.teams.slice(), ...(s.seed != null ? { seed: s.seed } : {}) });
626
+ } catch (err) {
627
+ this.app.hud.toast(err.status === 409 ? t('play.toast.busy') : err.message, 'error', 7000);
628
+ this.startButton.disabled = false;
629
+ return;
630
+ }
631
+ this.startButton.disabled = false;
632
+ this.begin(entry, humans, null, s);
633
+ }
634
+
635
+ // Shows a game that the server started, from the setup or as a match of a tournament, whose
636
+ // context names it and leads back to the tournament at the end.
637
+ begin(entry, humans, context = null, spec = null) {
638
+ this.game = { id: entry.id, humans, entry, started: false, paused: false, ended: false, source: null, sent: {}, busy: false, behind: false, context, spec };
639
+ if (!this.built) {
640
+ return;
641
+ }
642
+ this.menu.hidden = true;
643
+ this.end.hidden = true;
644
+ if (this.app.page === 'play') {
645
+ this.enterStage();
646
+ this.view.focus();
647
+ this.connect();
648
+ }
649
+ }
650
+
651
+ connect() {
652
+ const game = this.game;
653
+ this.wait.hidden = false;
654
+ // The scoreboard of the game before, its clock and score, leaves at once, and the new
655
+ // game's header brings the scoreboard back.
656
+ this.app.hud.hide();
657
+ const live = new LiveSource(game.id, {
658
+ open: (signal) => api.gameStream(game.id, signal),
659
+ onHeader: async (header, store) => {
660
+ if (this.game !== game) {
661
+ live.stop();
662
+ return;
663
+ }
664
+ const source = { kind: 'live', id: game.id, header, store, live, game: true };
665
+ game.source = source;
666
+ await this.app.setSource(source);
667
+ const tl = this.app.timeline;
668
+ tl.delay = PLAY_DELAY_FRAMES;
669
+ tl.lean = 0.5;
670
+ tl.jump = 8;
671
+ tl.goLive();
672
+ this.app.cameras.setMode('broadcast');
673
+ this.wait.hidden = true;
674
+ if (!game.started) {
675
+ game.started = true;
676
+ this.toggleCard(true, 8000);
677
+ await this.sendPause(false);
678
+ } else if (game.paused) {
679
+ this.hush();
680
+ }
681
+ },
682
+ onFrame: () => {},
683
+ onEnd: () => this.refresh(),
684
+ onError: (err) => {
685
+ this.wait.hidden = true;
686
+ if (game.live === live) {
687
+ game.live = null;
688
+ }
689
+ if (this.game === game && !game.ended) {
690
+ this.app.hud.toast(t('play.toast.streamStopped', { reason: err.message }), 'error', 7000);
691
+ }
692
+ },
693
+ });
694
+ game.live = live;
695
+ live.start();
696
+ }
697
+
698
+ async sendPause(paused) {
699
+ const game = this.game;
700
+ if (!game || game.ended) {
701
+ return;
702
+ }
703
+ game.paused = paused;
704
+ try {
705
+ this.apply(await api.pauseGame(game.id, paused));
706
+ } catch (err) {
707
+ this.app.hud.toast(err.message, 'error');
708
+ }
709
+ }
710
+
711
+ pause(on) {
712
+ const game = this.game;
713
+ if (!game || game.ended || !game.started) {
714
+ return;
715
+ }
716
+ this.menu.hidden = !on;
717
+ if (!on && !this.card.hidden && this.card.getAttribute('role') === 'dialog') {
718
+ // The card of the pause menu leaves with it, so play keeps its focus.
719
+ this.toggleCard(false);
720
+ }
721
+ if (on) {
722
+ this.releaseKeys();
723
+ this.resumeButton.focus();
724
+ this.hush();
725
+ } else if (this.app.timeline) {
726
+ this.view.focus();
727
+ this.app.timeline.goLive();
728
+ }
729
+ this.sendPause(on);
730
+ }
731
+
732
+ // A paused or ended game holds its picture, and the sound, which plays only while the
733
+ // timeline plays, falls silent. Resume plays the timeline again.
734
+ hush() {
735
+ const tl = this.app.timeline;
736
+ if (tl && this.game && this.app.source === this.game.source) {
737
+ tl.pause();
738
+ }
739
+ }
740
+
741
+ async quit() {
742
+ const game = this.game;
743
+ if (!game) {
744
+ return;
745
+ }
746
+ this.menu.hidden = true;
747
+ try {
748
+ this.apply(await api.quitGame(game.id));
749
+ } catch {
750
+ // The poll below reads the end.
751
+ }
752
+ this.refresh();
753
+ }
754
+
755
+ async goHome(quit = false) {
756
+ if (!quit && this.game && !this.game.ended) {
757
+ this.pause(true);
758
+ this.app.showPage('home');
759
+ document.getElementById('home-btn').focus();
760
+ return;
761
+ }
762
+ const game = this.game;
763
+ if (game && !game.ended) {
764
+ try {
765
+ this.apply(await api.quitGame(game.id));
766
+ await this.refresh();
767
+ } catch (err) {
768
+ this.app.hud.toast(err.message, 'error');
769
+ return;
770
+ }
771
+ if (!game.ended) {
772
+ this.app.hud.toast(t('play.toast.stillEnding'), 'error');
773
+ return;
774
+ }
775
+ }
776
+ if (game && game.source) {
777
+ game.source.live.stop();
778
+ }
779
+ this.showSetup();
780
+ this.app.showPage('home');
781
+ document.getElementById('home-btn').focus();
782
+ }
783
+
784
+ // The entry the server answers with, its state and score.
785
+ apply(entry) {
786
+ const game = this.game;
787
+ if (!game || !entry || entry.id !== game.id) {
788
+ return;
789
+ }
790
+ game.entry = entry;
791
+ // A registered team that keeps missing its time is named once, until it catches up again.
792
+ if ((entry.streak || 0) >= LATE_STREAK && !game.behind) {
793
+ game.behind = true;
794
+ this.notice(t('play.notice.late'));
795
+ } else if (!entry.streak && game.behind) {
796
+ game.behind = false;
797
+ }
798
+ this.updateStatus();
799
+ if (entry.state === 'paused' && entry.reason === 'input' && !game.waiting) {
800
+ game.waiting = true;
801
+ this.notice(t('play.notice.waiting'));
802
+ } else if (entry.state === 'playing' && game.waiting) {
803
+ game.waiting = false;
804
+ }
805
+ if ((entry.state === 'ended' || entry.state === 'failed') && !game.ended) {
806
+ this.finish(entry);
807
+ }
808
+ }
809
+
810
+ async refresh() {
811
+ if (!this.game || this.game.ended) {
812
+ return;
813
+ }
814
+ const game = this.game;
815
+ for (let tries = 0; tries < 40 && this.game === game && !game.ended; tries++) {
816
+ try {
817
+ this.apply(await api.game());
818
+ } catch {
819
+ return;
820
+ }
821
+ await new Promise((resolve) => setTimeout(resolve, 250));
822
+ }
823
+ }
824
+
825
+ finish(entry) {
826
+ const game = this.game;
827
+ game.ended = true;
828
+ this.menu.hidden = true;
829
+ this.toggleCard(false);
830
+ this.slowOffer.hidden = true;
831
+ this.marker.visible = false;
832
+ this.hush();
833
+ const score = entry.score || [0, 0];
834
+ const names = entry.teams ? summaryTeams(entry) : [t('play.end.left'), t('play.end.right')];
835
+ const header = game.source && game.source.header;
836
+ const store = game.source && game.source.store;
837
+ const frame = store && store.get(store.last);
838
+ const full = entry.ending === 'full time' && header && header.full_match && frame &&
839
+ !!(frame.flags & 1) &&
840
+ frame.step >= header.duration && frame.timeMs >= header.duration * 1800;
841
+ const played = entry.ending === 'full time' || entry.ending === 'episode';
842
+ const title = entry.state === 'failed' || entry.ending === 'policy' ? t('play.end.stopped') :
843
+ full ? t('play.end.fullTime') : played ? t('play.end.episode') : t('play.end.over');
844
+ this.end.setAttribute('aria-label', title);
845
+ const lines = [h('h2', {}, title),
846
+ h('p', { class: 'play-score' }, t('play.end.score', { left: names[0], leftScore: score[0], right: names[1], rightScore: score[1] }))];
847
+ if (entry.error) {
848
+ lines.push(h('p', { class: 'error-text' }, sentence(serverText(entry))));
849
+ } else if (entry.ending === 'timeout') {
850
+ lines.push(h('p', { class: 'muted' }, tn('play.end.timeout')));
851
+ }
852
+ if (entry.late) {
853
+ lines.push(h('p', { class: 'muted' }, t('play.end.late', { count: entry.late })));
854
+ }
855
+ if (entry.match) {
856
+ lines.push(h('p', { class: 'muted' }, t('play.end.saved')),
857
+ h('button', { type: 'button', class: 'btn primary', onclick: () => this.app.openMatchInWatch(entry.match) }, icon('watch'), t('play.end.watch')));
858
+ }
859
+ if (game.context) {
860
+ lines.push(h('p', { class: 'muted' }, game.context.label),
861
+ h('button', { type: 'button', class: 'btn', onclick: () => {
862
+ this.showSetup();
863
+ game.context.back();
864
+ } }, icon('trophy'), t('play.end.tournament')));
865
+ } else {
866
+ lines.push(h('button', { type: 'button', class: 'btn', onclick: () => this.start() }, icon('replay'), t('play.end.again')),
867
+ h('button', { type: 'button', class: 'btn', onclick: () => this.showSetup() }, icon('plus'), t('play.end.new')));
868
+ }
869
+ lines.push(h('button', { type: 'button', class: 'btn', onclick: () => this.goHome() }, icon('home'), t('play.home')));
870
+ const challenges = this.app.challengesPage;
871
+ this.endPanel.replaceChildren(...(challenges ? challenges.endCard(entry, game, lines) : lines));
872
+ this.end.hidden = false;
873
+ }
874
+
875
+ // Input settings, kept in the browser, with a tester of every device.
876
+
877
+ inputSettings() {
878
+ this.inputNote = h('p', { class: 'muted small', role: 'status' });
879
+ this.keyButtons = {};
880
+ const rows = BINDINGS.map((binding) => {
881
+ const button = h('button', { type: 'button', class: 'btn small key-bind' });
882
+ this.keyButtons[binding.id] = button;
883
+ button.addEventListener('click', () => this.listenKey(binding.id));
884
+ button.addEventListener('keydown', (e) => this.bindKey(e, binding.id));
885
+ button.addEventListener('blur', () => this.listenKey(null));
886
+ return h('tr', {}, h('th', {}, bindingLabel(binding)), h('td', {}, button));
887
+ });
888
+ this.deadZone = h('input', {
889
+ type: 'range', min: String(DEAD_ZONE.min), max: String(DEAD_ZONE.max), step: String(DEAD_ZONE.step),
890
+ 'aria-label': t('play.input.deadZone.range'),
891
+ });
892
+ this.deadZoneShown = h('output', { class: 'ability-value' });
893
+ this.deadZone.addEventListener('input', () => {
894
+ this.input.deadZone = Number(this.deadZone.value);
895
+ this.saveInput();
896
+ });
897
+ const options = [['auto', t('play.input.device.auto')], ['keyboard', KEYBOARD],
898
+ ...[0, 1, 2, 3].map((slot) => [`pad${slot}`, t('play.input.device.slot', { slot: slot + 1 })])];
899
+ this.deviceSelects = [0, 1].map((side) => {
900
+ const select = h('select', { 'aria-label': t(side ? 'play.input.device.right.select' : 'play.input.device.left.select') },
901
+ options.map(([value, text]) => h('option', { value }, text)));
902
+ select.addEventListener('change', () => {
903
+ const devices = this.input.devices.slice();
904
+ devices[side] = select.value;
905
+ if (devicesClash(devices)) {
906
+ select.value = this.input.devices[side];
907
+ this.inputNote.replaceChildren(...tn('play.input.device.clash'));
908
+ return;
909
+ }
910
+ this.input.devices = devices;
911
+ this.saveInput();
912
+ this.flush();
913
+ });
914
+ return select;
915
+ });
916
+ this.tester = h('div', { class: 'input-tester', tabindex: '0', 'aria-label': t('play.tester.label') },
917
+ this.testerLines = h('div', { 'aria-live': 'polite' }));
918
+ this.testerKeys = new Set();
919
+ const test = (e, down) => {
920
+ if (e.code === 'Tab' || e.ctrlKey || e.metaKey || e.altKey) {
921
+ return;
922
+ }
923
+ e.preventDefault();
924
+ e.stopPropagation();
925
+ if (down) {
926
+ this.testerKeys.add(e.code);
927
+ } else {
928
+ this.testerKeys.delete(e.code);
929
+ }
930
+ this.updateTester(true);
931
+ };
932
+ this.tester.addEventListener('keydown', (e) => test(e, true));
933
+ this.tester.addEventListener('keyup', (e) => test(e, false));
934
+ this.tester.addEventListener('blur', () => {
935
+ this.testerKeys.clear();
936
+ this.updateTester(true);
937
+ });
938
+ this.inputPanel = h('details', { class: 'input-settings' },
939
+ h('summary', {}, t('play.input.summary')),
940
+ h('p', { class: 'muted small' }, tn('play.input.lead')),
941
+ h('table', { class: 'controls-table key-bindings' }, h('tbody', {}, rows)),
942
+ h('div', { class: 'setup-row' },
943
+ h('label', { class: 'field' }, t('play.input.deadZone'), h('div', { class: 'ability' }, this.deadZone, this.deadZoneShown)),
944
+ ...[0, 1].map((side) => h('label', { class: 'field' }, t(side ? 'play.input.device.right' : 'play.input.device.left'), this.deviceSelects[side]))),
945
+ this.inputNote,
946
+ h('h3', {}, t('play.tester.title')),
947
+ this.tester,
948
+ h('div', { class: 'play-row' }, h('button', { type: 'button', class: 'btn', ...symbolOnly(t('play.input.reset')), onclick: () => {
949
+ this.input = defaultSettings();
950
+ this.saveInput();
951
+ this.inputNote.textContent = t('play.input.resetDone');
952
+ } }, icon('reset'))));
953
+ this.inputPanel.addEventListener('toggle', () => this.updateTester(true));
954
+ this.showInput();
955
+ return this.inputPanel;
956
+ }
957
+
958
+ // The settings as the panel and the tables show them.
959
+ showInput() {
960
+ for (const binding of BINDINGS) {
961
+ const button = this.keyButtons[binding.id];
962
+ const code = this.input.keys[binding.id];
963
+ button.replaceChildren(this.listening === binding.id ? t('play.input.pressKey') : chord(code));
964
+ button.setAttribute('aria-label', this.listening === binding.id ? t('play.input.bindWaiting', { action: bindingLabel(binding) })
965
+ : t('play.input.bind', { action: bindingLabel(binding), key: chordName(code) }));
966
+ button.setAttribute('aria-pressed', String(this.listening === binding.id));
967
+ }
968
+ this.deadZone.value = String(this.input.deadZone);
969
+ this.deadZoneShown.textContent = this.input.deadZone.toFixed(2);
970
+ this.deviceSelects.forEach((select, side) => {
971
+ select.value = this.input.devices[side];
972
+ });
973
+ this.keyTable.replaceChildren(h('h3', {}, KEYBOARD), controlsTable(keyboardControls(this.input)));
974
+ }
975
+
976
+ saveInput() {
977
+ this.releaseKeys();
978
+ this.keys = keyMap(this.input);
979
+ if (!saveSettings(this.input)) {
980
+ this.inputNote.replaceChildren(...tn('play.input.notKept'));
981
+ }
982
+ this.showInput();
983
+ this.updateTester(true);
984
+ }
985
+
986
+ listenKey(id) {
987
+ if (this.listening === id) {
988
+ return;
989
+ }
990
+ this.listening = id;
991
+ if (id) {
992
+ this.inputNote.replaceChildren(...tn('play.input.listening'));
993
+ }
994
+ this.showInput();
995
+ }
996
+
997
+ // The key pressed while a binding waits for one. Tab moves on, Esc keeps the old key, and a
998
+ // reserved, unknown or taken key is refused with the reason.
999
+ bindKey(e, id) {
1000
+ if (this.listening !== id || e.code === 'Tab') {
1001
+ return;
1002
+ }
1003
+ e.preventDefault();
1004
+ e.stopPropagation();
1005
+ if (e.code === 'Escape') {
1006
+ this.inputNote.textContent = '';
1007
+ this.listenKey(null);
1008
+ return;
1009
+ }
1010
+ const problem = keyProblem(this.input, id, e.code);
1011
+ if (problem) {
1012
+ this.inputNote.replaceChildren(...tn(`play.input.problem.${problem}`));
1013
+ return;
1014
+ }
1015
+ this.input.keys = { ...this.input.keys, [id]: e.code };
1016
+ this.listening = null;
1017
+ this.inputNote.replaceChildren(...tn('play.input.bound', { action: bindingLabel(BINDINGS.find((b) => b.id === id)), key: chord(e.code) }));
1018
+ this.saveInput();
1019
+ }
1020
+
1021
+ // What each device holds now, read live while the tester shows. The keyboard counts while the
1022
+ // tester has focus, and a controller shows its stick past the dead zone and its buttons.
1023
+ updateTester(now = false) {
1024
+ if (!this.tester || !this.inputPanel.open || this.setupView.hidden) {
1025
+ return;
1026
+ }
1027
+ const time = performance.now();
1028
+ if (!now && time - this.testerAt < TESTER_MS) {
1029
+ return;
1030
+ }
1031
+ this.testerAt = time;
1032
+ const names = (list) => (list.length ? [...new Set(list)].map(inputName).join(', ') : t('play.tester.nothing'));
1033
+ const keys = [...this.testerKeys];
1034
+ const mapped = keys.flatMap((code) => this.keys[code] || []);
1035
+ const unmapped = keys.filter((code) => !this.keys[code]);
1036
+ const held = document.activeElement === this.tester ? names(mapped) : t('play.tester.focus');
1037
+ // The keys that take no action show as keycaps, so the first line stands in the list as
1038
+ // the text it compares by and is drawn apart.
1039
+ const lines = [`${held} ${unmapped.join(' ')}`];
1040
+ const first = () => (unmapped.length ? tn('play.tester.keyboardUnused', { held, keys: joinAnd(unmapped.map((code) => chord(code))) })
1041
+ : [t('play.tester.keyboard', { held })]);
1042
+ const list = navigator.getGamepads ? Array.from(navigator.getGamepads() || []) : [];
1043
+ for (const pad of list) {
1044
+ const device = pad && pad.connected ? this.pads.get(pad.index) : null;
1045
+ if (!device) {
1046
+ continue;
1047
+ }
1048
+ const { held, x, y } = this.padHeld(pad, device.entry.layout);
1049
+ lines.push(t('play.tester.pad', { slot: pad.index + 1, x: x.toFixed(2), y: y.toFixed(2), held: names([...held]) }));
1050
+ }
1051
+ if (lines.length === 1) {
1052
+ lines.push(t('play.tester.noPad'));
1053
+ }
1054
+ const text = lines.join('\n');
1055
+ if (text !== this.testerText) {
1056
+ this.testerText = text;
1057
+ this.testerLines.replaceChildren(...lines.map((line, i) => h('div', {}, ...(i ? [line] : first()))));
1058
+ }
1059
+ }
1060
+
1061
+ // Input.
1062
+
1063
+ onKey(e, down) {
1064
+ const mapped = this.keys[e.code];
1065
+ if (!down && mapped && this.keyboard.held.size) {
1066
+ this.keyboard.set(mapped, false);
1067
+ this.flush();
1068
+ }
1069
+ if (this.padSetup) {
1070
+ // The setup panel takes the keyboard, Escape cancels it and Tab and Enter work its buttons.
1071
+ if (down && e.code === 'Escape' && this.app.page === 'play') {
1072
+ e.preventDefault();
1073
+ e.stopImmediatePropagation();
1074
+ this.closePadSetup(false);
1075
+ }
1076
+ return;
1077
+ }
1078
+ if (this.app.page !== 'play' || !this.game || this.game.ended || !this.game.started) {
1079
+ return;
1080
+ }
1081
+ if (e.defaultPrevented || e.target.closest('input, select, textarea, [contenteditable], .dd-panel, dialog') || e.ctrlKey || e.metaKey || e.altKey) {
1082
+ return;
1083
+ }
1084
+ if (e.code === 'Escape' && down) {
1085
+ e.preventDefault();
1086
+ e.stopImmediatePropagation();
1087
+ if (!this.menu.hidden && !this.card.hidden) {
1088
+ this.closeCard();
1089
+ } else {
1090
+ this.pause(this.menu.hidden);
1091
+ }
1092
+ return;
1093
+ }
1094
+ if (e.target.closest('button, a') || !this.menu.hidden) {
1095
+ return;
1096
+ }
1097
+ if (down && e.code === 'KeyH') {
1098
+ this.app.toggleHelp();
1099
+ } else if (down && e.code === 'KeyM') {
1100
+ this.app.setMuted(!this.app.settings.muted);
1101
+ } else if (down && e.code === 'KeyF') {
1102
+ this.app.toggleFullscreen();
1103
+ }
1104
+ const names = mapped;
1105
+ if (!names) {
1106
+ return;
1107
+ }
1108
+ e.preventDefault();
1109
+ e.stopImmediatePropagation();
1110
+ if (!this.menu.hidden || e.repeat) {
1111
+ return;
1112
+ }
1113
+ this.use(this.keyboard);
1114
+ this.keyboard.set(names, down);
1115
+ this.flush();
1116
+ }
1117
+
1118
+ releaseKeys() {
1119
+ if (this.keyboard.held.size) {
1120
+ this.keyboard.held.clear();
1121
+ this.flush();
1122
+ }
1123
+ }
1124
+
1125
+ pollPads() {
1126
+ const list = navigator.getGamepads ? Array.from(navigator.getGamepads() || []) : [];
1127
+ const seen = new Set();
1128
+ for (const pad of list) {
1129
+ if (!pad || !pad.connected) {
1130
+ continue;
1131
+ }
1132
+ seen.add(pad.index);
1133
+ let device = this.pads.get(pad.index);
1134
+ if (!device || device.gpId !== pad.id) {
1135
+ device = new Device(`pad${pad.index}`, CONTROLLER);
1136
+ device.index = pad.index;
1137
+ device.gpId = pad.id;
1138
+ device.mapping = pad.mapping;
1139
+ device.buttons = {};
1140
+ const stored = loadLayouts()[pad.id];
1141
+ device.custom = !!(stored && stored.layout);
1142
+ device.entry = device.custom ? stored : { style: detectStyle(pad.id), layout: STANDARD };
1143
+ this.pads.set(pad.index, device);
1144
+ this.notice(this.pads.size > 1 ? t('play.notice.connectedNumber', { number: this.pads.size }) : t('play.notice.connected'));
1145
+ if (pad.mapping !== 'standard' && !device.custom && this.built) {
1146
+ this.offerPad = pad.index;
1147
+ this.offer.hidden = false;
1148
+ }
1149
+ }
1150
+ if (this.padSetup) {
1151
+ // While the setup shows, no controller plays. The controller being set up works the
1152
+ // style choice, then records its presses. Another controller works the buttons of
1153
+ // the panel while presses are taken, and before the style is chosen a press on it
1154
+ // sets that one up instead, so each person sets up their own.
1155
+ const { now, x, y } = this.padHeld(pad, device.entry.layout);
1156
+ const before = device.buttons;
1157
+ const pressed = POSITIONS.some((pos) => now[pos] && !before[pos]);
1158
+ device.buttons = now;
1159
+ const s = this.padSetup;
1160
+ const mine = s.index === pad.index;
1161
+ const works = mine ? s.step < 0 : s.step >= 0;
1162
+ const move = this.navStep(device, this.navDirection(device, now, x, y), works);
1163
+ if (mine && s.step >= 0) {
1164
+ this.recordPad(pad);
1165
+ } else if (works) {
1166
+ this.navigate([this.setupBox], (pos) => now[pos] && !before[pos], move);
1167
+ } else if (pressed) {
1168
+ this.lastPad = device;
1169
+ this.padSetup = { ...this.padSetup, index: pad.index, id: pad.id, style: device.entry.style, layout: { ...device.entry.layout } };
1170
+ this.renderSetup();
1171
+ }
1172
+ continue;
1173
+ }
1174
+ this.readPad(pad, device);
1175
+ }
1176
+ this.updateTester();
1177
+ for (const [index, device] of this.pads) {
1178
+ if (!seen.has(index)) {
1179
+ this.pads.delete(index);
1180
+ device.held.clear();
1181
+ if (this.padSetup && this.padSetup.index === index) {
1182
+ this.closePadSetup(false);
1183
+ }
1184
+ this.notice(t('play.notice.disconnected'));
1185
+ this.flush();
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ // The positions a controller holds in a layout, and the actions they press.
1191
+ padHeld(pad, layout) {
1192
+ const button = (i) => !!(pad.buttons[i] && (pad.buttons[i].pressed || pad.buttons[i].value > AXIS_ON));
1193
+ const axis = (src) => (src && src.a !== undefined ? src.s * (pad.axes[src.a] || 0) : 0);
1194
+ const on = (src) => {
1195
+ if (!src) {
1196
+ return false;
1197
+ }
1198
+ if (src.b !== undefined) {
1199
+ return button(src.b);
1200
+ }
1201
+ if (src.v !== undefined) {
1202
+ return hatOn(pad.axes[src.a], src.v);
1203
+ }
1204
+ return axis(src) > (src.t ?? AXIS_ON);
1205
+ };
1206
+ const now = {};
1207
+ for (const pos of POSITIONS) {
1208
+ now[pos] = on(layout[pos]);
1209
+ }
1210
+ const held = new Set();
1211
+ for (const [pos, names] of Object.entries(PAD_BUTTONS)) {
1212
+ if (now[pos]) {
1213
+ names.forEach((n) => held.add(n));
1214
+ }
1215
+ }
1216
+ const x = axis(layout.stickX);
1217
+ const y = axis(layout.stickY);
1218
+ // The stick counts along each axis past the dead zone of the input settings.
1219
+ stickDirections(x, y, this.input.deadZone).forEach((name) => held.add(name));
1220
+ return { now, held, x, y };
1221
+ }
1222
+
1223
+ readPad(pad, device) {
1224
+ const { now, held, x, y } = this.padHeld(pad, device.entry.layout);
1225
+ const before = device.buttons;
1226
+ device.buttons = now;
1227
+ const edge = (pos) => now[pos] && !before[pos];
1228
+ const scopes = this.navScopes();
1229
+ const move = this.navStep(device, this.navDirection(device, now, x, y), !!scopes);
1230
+ const live = this.game && !this.game.ended && this.game.started && this.app.page === 'play';
1231
+ if (live && edge('start')) {
1232
+ // The controller that opens the menu is the one its setup button sets up.
1233
+ this.lastPad = device;
1234
+ this.pause(this.menu.hidden);
1235
+ return;
1236
+ }
1237
+ if (live && edge('back')) {
1238
+ this.lastPad = device;
1239
+ this.toggleCard(this.card.hidden);
1240
+ }
1241
+ if (scopes) {
1242
+ // A button pressed in a menu stays out of play until it is released, so the press that
1243
+ // picks Resume or Play again passes no ball.
1244
+ device.navHeld = new Set([...held].filter((n) => !DIRECTIONS.has(n) && !device.held.has(n)));
1245
+ this.navigate(scopes, edge, move);
1246
+ return;
1247
+ }
1248
+ if (!live) {
1249
+ return;
1250
+ }
1251
+ if (device.navHeld) {
1252
+ for (const n of device.navHeld) {
1253
+ if (held.has(n)) {
1254
+ held.delete(n);
1255
+ } else {
1256
+ device.navHeld.delete(n);
1257
+ }
1258
+ }
1259
+ }
1260
+ const same = held.size === device.held.size && [...held].every((n) => device.held.has(n));
1261
+ if (same) {
1262
+ return;
1263
+ }
1264
+ for (const n of held) {
1265
+ if (!device.held.has(n) && !DIRECTIONS.has(n)) {
1266
+ device.pressed.add(n);
1267
+ }
1268
+ }
1269
+ device.held = held;
1270
+ device.usedAt = performance.now();
1271
+ this.use(device);
1272
+ this.flush();
1273
+ }
1274
+
1275
+ // Menu navigation by controller.
1276
+
1277
+ // The parts of the page that a controller works as a menu, or null while the buttons play.
1278
+ navScopes() {
1279
+ if (!this.built || this.app.page !== 'play') {
1280
+ return null;
1281
+ }
1282
+ if (this.padSetup) {
1283
+ return [this.setupBox];
1284
+ }
1285
+ const offer = this.offer.hidden ? [] : [this.offer];
1286
+ if (!this.setupView.hidden) {
1287
+ return [this.setupView, ...offer];
1288
+ }
1289
+ if (!this.end.hidden) {
1290
+ return [this.end];
1291
+ }
1292
+ if (!this.menu.hidden) {
1293
+ return [this.menu, ...(this.card.hidden ? [] : [this.card]), ...offer];
1294
+ }
1295
+ return null;
1296
+ }
1297
+
1298
+ // The direction a controller points, the D-pad first, then the left stick past half way.
1299
+ navDirection(device, now, x, y) {
1300
+ const pull = { left: -x, right: x, up: -y, down: y };
1301
+ const dpad = [...DIRECTIONS].find((d) => now[d]);
1302
+ if (device.navDir === 'held') {
1303
+ return dpad || Object.values(pull).some((v) => v > NAV_RELEASE) ? 'held' : null;
1304
+ }
1305
+ if (dpad) {
1306
+ return dpad;
1307
+ }
1308
+ if (device.navDir && pull[device.navDir] > NAV_RELEASE) {
1309
+ return device.navDir;
1310
+ }
1311
+ const best = Object.keys(pull).reduce((a, b) => (pull[b] > pull[a] ? b : a));
1312
+ return pull[best] > STICK ? best : null;
1313
+ }
1314
+
1315
+ // The move a direction makes this frame, once on its press and then repeated while held. The
1316
+ // direction is followed during play too, so a menu that opens under a held stick waits for a
1317
+ // fresh push.
1318
+ navStep(device, dir, active) {
1319
+ const time = performance.now();
1320
+ let move = null;
1321
+ if (dir !== device.navDir) {
1322
+ move = dir;
1323
+ device.navAt = time + NAV_DELAY_MS;
1324
+ } else if (dir && device.navAt && time >= device.navAt) {
1325
+ move = dir;
1326
+ device.navAt = time + NAV_REPEAT_MS;
1327
+ }
1328
+ device.navDir = dir;
1329
+ if (!active || dir === 'held') {
1330
+ device.navAt = 0;
1331
+ return null;
1332
+ }
1333
+ return move;
1334
+ }
1335
+
1336
+ navTargets(scopes) {
1337
+ return scopes.flatMap((scope) => [...scope.querySelectorAll(FOCUSABLE)])
1338
+ .filter((el) => !el.disabled && !el.closest('[hidden]') && el.getClientRects().length);
1339
+ }
1340
+
1341
+ // South works the focused control, east goes back, and a direction moves the focus in reading
1342
+ // order, or changes a select or a slider with left and right. The first press focuses the main
1343
+ // action of the menu.
1344
+ navigate(scopes, edge, move) {
1345
+ if (edge('east')) {
1346
+ this.navBack();
1347
+ return;
1348
+ }
1349
+ const south = edge('south');
1350
+ if (!south && !move) {
1351
+ return;
1352
+ }
1353
+ const targets = this.navTargets(scopes);
1354
+ const el = document.activeElement;
1355
+ const at = targets.indexOf(el);
1356
+ if (at < 0) {
1357
+ const first = targets.find((t) => t.classList.contains('primary')) || targets[0];
1358
+ if (first) {
1359
+ padFocus(first);
1360
+ }
1361
+ return;
1362
+ }
1363
+ if (south) {
1364
+ this.navActivate(el);
1365
+ return;
1366
+ }
1367
+ if ((move === 'left' || move === 'right') && this.navAdjust(el, move === 'right' ? 1 : -1)) {
1368
+ return;
1369
+ }
1370
+ const next = targets[at + (move === 'up' || move === 'left' ? -1 : 1)];
1371
+ if (next) {
1372
+ padFocus(next);
1373
+ }
1374
+ }
1375
+
1376
+ // A select steps to its next option and wraps, a slider waits for left and right, and any
1377
+ // other control takes a click, which toggles a checkbox.
1378
+ navActivate(el) {
1379
+ if (el.tagName === 'SELECT') {
1380
+ if (el.options.length) {
1381
+ el.selectedIndex = (el.selectedIndex + 1) % el.options.length;
1382
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1383
+ }
1384
+ } else if (el.type !== 'range') {
1385
+ el.click();
1386
+ }
1387
+ }
1388
+
1389
+ // Left and right change a select or a slider directly, and report whether they did.
1390
+ navAdjust(el, step) {
1391
+ if (el.tagName === 'SELECT') {
1392
+ const i = el.selectedIndex + step;
1393
+ if (i >= 0 && i < el.options.length) {
1394
+ el.selectedIndex = i;
1395
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1396
+ }
1397
+ return true;
1398
+ }
1399
+ if (el.type === 'range') {
1400
+ if (step > 0) {
1401
+ el.stepUp();
1402
+ } else {
1403
+ el.stepDown();
1404
+ }
1405
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1406
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1407
+ return true;
1408
+ }
1409
+ return false;
1410
+ }
1411
+
1412
+ // East closes the setup panel or the controls card, resumes from the pause menu, and leaves
1413
+ // the end card for the setup or the Challenges page, as Esc does.
1414
+ navBack() {
1415
+ if (this.padSetup) {
1416
+ this.closePadSetup(false);
1417
+ } else if (!this.menu.hidden && !this.card.hidden) {
1418
+ const inside = this.card.contains(document.activeElement);
1419
+ this.closeCard();
1420
+ if (inside) {
1421
+ padFocus(this.controlsButton);
1422
+ }
1423
+ } else if (!this.menu.hidden) {
1424
+ this.pause(false);
1425
+ } else if (!this.end.hidden) {
1426
+ this.closeEnd();
1427
+ }
1428
+ }
1429
+
1430
+ // The devices of each person's side. One person takes every device. Two people take the
1431
+ // devices the input settings name for their sides, and a side left to the page takes, of the
1432
+ // devices the other side leaves, the keyboard and the first controller for the left and the
1433
+ // second controller for the right, or with one controller, the keyboard and the controller.
1434
+ devicesOf(side) {
1435
+ const pads = [...this.pads.keys()].sort((a, b) => a - b).map((i) => this.pads.get(i));
1436
+ // The touch screen, once its controls exist, goes with the keyboard.
1437
+ const keys = this.touch ? [this.keyboard, this.touch] : [this.keyboard];
1438
+ const all = [...keys, ...pads];
1439
+ if (!this.game || !this.game.humans[0] || !this.game.humans[1]) {
1440
+ return all;
1441
+ }
1442
+ const [own, other] = [this.input.devices[side], this.input.devices[1 - side]];
1443
+ const named = (device, id) => device.id === id || (id === 'keyboard' && device === this.touch);
1444
+ if (own !== 'auto') {
1445
+ return all.filter((device) => named(device, own));
1446
+ }
1447
+ if (other !== 'auto') {
1448
+ return all.filter((device) => !named(device, other));
1449
+ }
1450
+ if (pads.length >= 2) {
1451
+ return side === 0 ? [...keys, pads[0]] : [pads[1]];
1452
+ }
1453
+ return side === 0 ? keys : pads.slice(0, 1);
1454
+ }
1455
+
1456
+ activeOf(side) {
1457
+ const devices = this.devicesOf(side);
1458
+ return devices.reduce((best, d) => (!best || d.usedAt > best.usedAt ? d : best), null);
1459
+ }
1460
+
1461
+ // Whichever device was used last drives its side, and a change shows its controls.
1462
+ use(device) {
1463
+ const pad = device !== this.keyboard && device !== this.touch;
1464
+ const changed = this.lastDevice !== device.label || (pad && this.lastPad !== device);
1465
+ if (pad) {
1466
+ this.lastPad = device;
1467
+ this.refreshStyle();
1468
+ }
1469
+ if (changed) {
1470
+ this.lastDevice = device.label;
1471
+ if (this.game && this.game.started) {
1472
+ this.toggleCard(true, 5000);
1473
+ }
1474
+ }
1475
+ this.updateStatus();
1476
+ }
1477
+
1478
+ flush() {
1479
+ const game = this.game;
1480
+ if (!game || game.ended || !game.started) {
1481
+ return;
1482
+ }
1483
+ game.humans.forEach((human, side) => {
1484
+ if (human) {
1485
+ this.send(side, false);
1486
+ }
1487
+ });
1488
+ }
1489
+
1490
+ async send(side, always) {
1491
+ const game = this.game;
1492
+ const device = this.activeOf(side);
1493
+ const held = device ? [...device.held].sort() : [];
1494
+ const pressed = device ? [...device.pressed] : [];
1495
+ const key = held.join(',');
1496
+ if (!always && !pressed.length && game.sent[side] === key) {
1497
+ return;
1498
+ }
1499
+ if (game.busy) {
1500
+ game.dirty = true;
1501
+ return;
1502
+ }
1503
+ if (device) {
1504
+ device.pressed.clear();
1505
+ }
1506
+ game.sent[side] = key;
1507
+ game.busy = true;
1508
+ try {
1509
+ this.apply(await api.gameInput(game.id, { side, held, pressed }));
1510
+ } catch {
1511
+ // The heartbeat sends again.
1512
+ } finally {
1513
+ game.busy = false;
1514
+ }
1515
+ if (game.dirty) {
1516
+ game.dirty = false;
1517
+ this.flush();
1518
+ }
1519
+ }
1520
+
1521
+ heartbeat() {
1522
+ const game = this.game;
1523
+ if (!game || game.ended || !game.started || game.busy) {
1524
+ return;
1525
+ }
1526
+ const side = game.humans[0] ? 0 : 1;
1527
+ this.send(side, true);
1528
+ }
1529
+
1530
+ // Notices and cards.
1531
+
1532
+ notice(text) {
1533
+ if (this.app.page === 'play') {
1534
+ this.app.hud.toast(text, 'info', 3000);
1535
+ }
1536
+ this.refreshStyle();
1537
+ this.updatePadNote();
1538
+ this.updateStatus();
1539
+ }
1540
+
1541
+ // The controller in use, the last one used or else the first.
1542
+ activePad() {
1543
+ if (this.lastPad && this.pads.get(this.lastPad.index) === this.lastPad) {
1544
+ return this.lastPad;
1545
+ }
1546
+ const first = [...this.pads.keys()].sort((a, b) => a - b)[0];
1547
+ return first === undefined ? null : this.pads.get(first);
1548
+ }
1549
+
1550
+ // The number of a controller, by the order the browser lists them.
1551
+ padNumber(index) {
1552
+ return [...this.pads.keys()].sort((a, b) => a - b).indexOf(index) + 1;
1553
+ }
1554
+
1555
+ padStyle() {
1556
+ const pad = this.activePad();
1557
+ return pad ? pad.entry.style : 'xbox';
1558
+ }
1559
+
1560
+ // What the browser reports of the controller in use, to tell a standard one from a custom one.
1561
+ padReport(pad) {
1562
+ const mapping = t(pad.mapping === 'standard' ? 'play.pad.mapping.standard' : 'play.pad.mapping.custom');
1563
+ return t(pad.custom ? 'play.pad.reportSetUp' : 'play.pad.report', { id: pad.gpId, mapping });
1564
+ }
1565
+
1566
+ // The tables, the pause menu and the Help page draw the style of the controller in use.
1567
+ refreshStyle() {
1568
+ const style = this.padStyle();
1569
+ if (!this.built || style === this.shownStyle) {
1570
+ return;
1571
+ }
1572
+ this.shownStyle = style;
1573
+ const name = PAD_STYLES[style].name;
1574
+ this.padTable.replaceChildren(h('h3', {}, t('play.controls.pad', { style: name })), controlsTable(PAD_CONTROLS, style));
1575
+ this.menuHint.replaceChildren(...tn('play.pause.hint', { select: padIcon(style, 'south'), resume: padIcon(style, 'east') }));
1576
+ const help = document.querySelector('.pad-help');
1577
+ if (help) {
1578
+ help.replaceChildren(...padHelp(style));
1579
+ }
1580
+ }
1581
+
1582
+ // The report line of a controller and the action that sets it up.
1583
+ padTools(pad) {
1584
+ return pad ? [h('p', { class: 'muted small pad-report' }, this.padReport(pad)),
1585
+ h('button', { type: 'button', class: 'btn', ...symbolOnly(t('play.padSetup.open')), onclick: () => this.openPadSetup() }, icon('gamepad'))] : [];
1586
+ }
1587
+
1588
+ updatePadNote() {
1589
+ if (this.padNote) {
1590
+ const n = this.pads.size;
1591
+ const pad = this.activePad();
1592
+ this.padNote.replaceChildren(
1593
+ h('p', {}, n ? tn('play.pads.connected', { count: n }) : tn('play.pads.none')),
1594
+ ...this.padTools(pad));
1595
+ this.menuHint.hidden = !n;
1596
+ }
1597
+ }
1598
+
1599
+ updateStatus() {
1600
+ if (!this.status) {
1601
+ return;
1602
+ }
1603
+ const pads = this.pads.size;
1604
+ const parts = [`${this.lastDevice || KEYBOARD}`];
1605
+ if (pads && this.lastDevice !== CONTROLLER) {
1606
+ parts.push(t('play.status.pads', { count: pads }));
1607
+ }
1608
+ const entry = this.game && this.game.entry;
1609
+ // The engine's action of each person, as the game last reported it, once every second or
1610
+ // so, which shows what GRF's controller logic made of the keys and buttons.
1611
+ const taken = entry && Array.isArray(entry.actions) ? entry.actions : [];
1612
+ const two = this.game && this.game.humans[0] && this.game.humans[1];
1613
+ taken.forEach((action, side) => {
1614
+ if (typeof action === 'number') {
1615
+ // GRF keeps a held direction, sprint and dribble as sticky actions, so the engine
1616
+ // takes no new action while they stay held, and the line names what is held.
1617
+ const where = two ? (side ? '.right' : '.left') : '';
1618
+ parts.push(action === 0 ? t(`play.status.idle${where}`) : t(`play.status.action${where}`, { action: engineAction(action) }));
1619
+ parts.push(...this.heldParts(side, action));
1620
+ }
1621
+ });
1622
+ const late = entry && entry.late;
1623
+ if (late) {
1624
+ parts.push(t('play.status.late', { count: late }));
1625
+ }
1626
+ this.status.textContent = parts.join(', ');
1627
+ }
1628
+
1629
+ // The sticky actions that a person's side holds, the direction while the engine's action is
1630
+ // none of the directions, then sprint and dribble, each as a part of the status line.
1631
+ heldParts(side, action) {
1632
+ const device = this.game && this.game.humans[side] ? this.activeOf(side) : null;
1633
+ if (!device) {
1634
+ return [];
1635
+ }
1636
+ const parts = [];
1637
+ const direction = action === 0 || action > 8 ? this.heldDirection(side) : null;
1638
+ if (direction) {
1639
+ parts.push(t('play.status.held.direction', { direction }));
1640
+ }
1641
+ for (const name of ['sprint', 'dribble']) {
1642
+ if (device.held.has(name)) {
1643
+ parts.push(t(`play.status.held.${name}`));
1644
+ }
1645
+ }
1646
+ return parts;
1647
+ }
1648
+
1649
+ // The direction a person's side holds, as GRF names its sticky direction, or null.
1650
+ heldDirection(side) {
1651
+ const device = this.game && this.game.humans[side] ? this.activeOf(side) : null;
1652
+ if (!device) {
1653
+ return null;
1654
+ }
1655
+ const vertical = ['up', 'down'].find((n) => device.held.has(n) && !device.held.has(n === 'up' ? 'down' : 'up'));
1656
+ const horizontal = ['left', 'right'].find((n) => device.held.has(n) && !device.held.has(n === 'left' ? 'right' : 'left'));
1657
+ const names = [vertical, horizontal].filter(Boolean).map((n) => STICKY_NAMES[n]);
1658
+ // The names join as one of GRF's directions, such as top left, in the page's language.
1659
+ return names.length ? tOr(`play.engineAction.${names.join('_')}`, names.join(' ')) : null;
1660
+ }
1661
+
1662
+ // The controls card from the pause menu, a dialog that takes the focus until Esc or the
1663
+ // back button closes it, and gives the focus back to Controls.
1664
+ openCard() {
1665
+ // The card hides and shows again, so the dialogs of the page take it up and focus it.
1666
+ this.card.hidden = true;
1667
+ this.toggleCard(true);
1668
+ this.card.setAttribute('role', 'dialog');
1669
+ this.card.setAttribute('aria-label', t('play.card.dialog.label'));
1670
+ }
1671
+
1672
+ closeCard() {
1673
+ const inside = this.card.contains(document.activeElement);
1674
+ this.toggleCard(false);
1675
+ if (inside) {
1676
+ this.controlsButton.focus();
1677
+ }
1678
+ }
1679
+
1680
+ toggleCard(on, ms = 0) {
1681
+ clearTimeout(this.cardTimer);
1682
+ if (this.card.hidden && this.card.getAttribute('role') === 'dialog') {
1683
+ // A card that shows during play is no dialog, so it never takes the focus of the game.
1684
+ this.card.removeAttribute('role');
1685
+ this.card.removeAttribute('aria-label');
1686
+ this.card.removeAttribute('tabindex');
1687
+ }
1688
+ const pad = this.activePad();
1689
+ if (pad && this.lastDevice === CONTROLLER) {
1690
+ const style = pad.entry.style;
1691
+ this.card.replaceChildren(h('h3', {}, t('play.controls.pad', { style: PAD_STYLES[style].name })), controlsTable(PAD_CONTROLS, style), ...this.padTools(pad));
1692
+ } else {
1693
+ this.card.replaceChildren(h('h3', {}, KEYBOARD), controlsTable(keyboardControls(this.input)), ...this.padTools(pad));
1694
+ }
1695
+ this.card.hidden = !on;
1696
+ if (on && ms) {
1697
+ this.cardTimer = setTimeout(() => {
1698
+ this.card.hidden = true;
1699
+ }, ms);
1700
+ }
1701
+ }
1702
+
1703
+ // The controller setup, which asks the style of the controller, then each button in turn by
1704
+ // its position, and keeps the layout for the controller's id.
1705
+
1706
+ openPadSetup(index = null) {
1707
+ const pad = (index !== null && this.pads.get(index)) || this.activePad();
1708
+ if (!this.built || !pad) {
1709
+ this.app.hud.toast(t('play.toast.noPad'), 'info', 4000);
1710
+ return;
1711
+ }
1712
+ if (this.playing) {
1713
+ this.pause(true);
1714
+ }
1715
+ // The notice of a controller without the standard layout comes back on Cancel, unless
1716
+ // the setup saved a layout for that controller.
1717
+ const offered = !this.offer.hidden;
1718
+ this.offer.hidden = true;
1719
+ this.card.hidden = true;
1720
+ this.padSetup = {
1721
+ index: pad.index, id: pad.gpId, style: pad.entry.style,
1722
+ layout: { ...pad.entry.layout }, step: -1, armed: false, still: null, stillAt: 0, rest: [],
1723
+ back: document.activeElement, offered,
1724
+ };
1725
+ this.setupPanel.hidden = false;
1726
+ this.renderSetup();
1727
+ }
1728
+
1729
+ closePadSetup(save) {
1730
+ const s = this.padSetup;
1731
+ if (!s) {
1732
+ return;
1733
+ }
1734
+ this.padSetup = null;
1735
+ this.setupPanel.hidden = true;
1736
+ const device = this.pads.get(s.index);
1737
+ if (save) {
1738
+ const entry = { style: s.style, layout: s.layout };
1739
+ saveLayout(s.id, entry);
1740
+ if (device) {
1741
+ device.entry = entry;
1742
+ device.custom = true;
1743
+ // The new layout reads the buttons still held differently, so each waits for its release.
1744
+ device.buttons = Object.fromEntries(POSITIONS.map((pos) => [pos, true]));
1745
+ device.navDir = 'held';
1746
+ device.navAt = 0;
1747
+ }
1748
+ this.shownStyle = null;
1749
+ this.app.hud.toast(t('play.toast.padSaved'), 'info', 4000);
1750
+ }
1751
+ const offeredPad = this.offerPad !== null ? this.pads.get(this.offerPad) : null;
1752
+ if (s.offered && offeredPad && !offeredPad.custom) {
1753
+ this.offer.hidden = false;
1754
+ }
1755
+ this.refreshStyle();
1756
+ this.updatePadNote();
1757
+ if (!this.menu.hidden) {
1758
+ this.resumeButton.focus();
1759
+ } else if (s.back && s.back.isConnected) {
1760
+ s.back.focus();
1761
+ }
1762
+ }
1763
+
1764
+ setupStep(step) {
1765
+ const s = this.padSetup;
1766
+ s.step = step;
1767
+ s.armed = false;
1768
+ if (step >= SETUP_STEPS.length) {
1769
+ this.closePadSetup(true);
1770
+ return;
1771
+ }
1772
+ this.renderSetup();
1773
+ }
1774
+
1775
+ renderSetup() {
1776
+ const s = this.padSetup;
1777
+ const button = (text, onclick, cls = 'btn') => h('button', { type: 'button', class: cls, onclick }, text);
1778
+ const cancel = h('button', { type: 'button', class: 'btn', ...symbolOnly(t('play.padSetup.cancel')), onclick: () => this.closePadSetup(false) }, icon('close'));
1779
+ if (s.step < 0) {
1780
+ const choices = Object.entries(PAD_STYLES).map(([style, info]) => {
1781
+ const b = button(info.name, () => {
1782
+ s.style = style;
1783
+ this.setupStep(0);
1784
+ }, style === s.style ? 'btn primary' : 'btn');
1785
+ b.setAttribute('aria-pressed', String(style === s.style));
1786
+ return b;
1787
+ });
1788
+ this.setupBox.replaceChildren(h('h2', {}, t('play.padSetup.open')),
1789
+ h('p', {}, t('play.padSetup.style')),
1790
+ h('div', { class: 'pad-setup-row' }, choices),
1791
+ h('p', { class: 'muted small' }, t('play.padSetup.which', { number: this.padNumber(s.index), id: s.id })),
1792
+ ...(this.pads.size > 1 ? [h('p', { class: 'muted small' }, t('play.padSetup.another'))] : []),
1793
+ h('div', { class: 'pad-setup-row' }, cancel));
1794
+ choices[Object.keys(PAD_STYLES).indexOf(s.style)].focus();
1795
+ return;
1796
+ }
1797
+ const pos = SETUP_STEPS[s.step];
1798
+ let prompt;
1799
+ if (pos === 'stickX') {
1800
+ prompt = t('play.padSetup.stickX');
1801
+ } else if (pos === 'stickY') {
1802
+ prompt = t('play.padSetup.stickY');
1803
+ } else if (DIRECTIONS.has(pos)) {
1804
+ prompt = t(`play.padSetup.dpad.${pos}`);
1805
+ } else {
1806
+ prompt = t('play.padSetup.press', { button: padInfo(s.style, pos)[3] });
1807
+ }
1808
+ const icon = pos.startsWith('stick') ? 'stick' : DIRECTIONS.has(pos) ? 'dpad' : pos;
1809
+ const sketch = h('div', { class: 'pad-sketch-box' });
1810
+ sketch.innerHTML = padSketch(s.style, pos);
1811
+ const skip = button(t('play.padSetup.skip'), () => this.setupStep(s.step + 1));
1812
+ this.setupBox.replaceChildren(h('h2', {}, t('play.padSetup.open')),
1813
+ h('p', { class: 'muted small' }, t('play.padSetup.step', { step: s.step + 1, total: SETUP_STEPS.length, style: PAD_STYLES[s.style].name })),
1814
+ h('p', { class: 'pad-prompt keys', 'aria-live': 'polite' }, padIcon(s.style, icon), prompt),
1815
+ sketch,
1816
+ h('p', { class: 'muted small' }, tn('play.padSetup.release')),
1817
+ h('div', { class: 'pad-setup-row' }, button(t('play.padSetup.back'), () => this.setupStep(s.step - 1)), skip, cancel));
1818
+ skip.focus();
1819
+ }
1820
+
1821
+ // One frame of the controller being set up. It waits until every button is up and the axes
1822
+ // rest for a moment, then takes the first button pressed, or the first axis that moves well
1823
+ // away from its rest past the threshold, with the sign of the move.
1824
+ recordPad(pad) {
1825
+ const s = this.padSetup;
1826
+ if (s.step < 0) {
1827
+ return;
1828
+ }
1829
+ const time = performance.now();
1830
+ const axes = Array.from(pad.axes);
1831
+ if (!s.still || axes.some((v, i) => Math.abs(v - (s.still[i] ?? 0)) > 0.1)) {
1832
+ s.still = axes;
1833
+ s.stillAt = time;
1834
+ }
1835
+ const pressed = pad.buttons.map((b) => !!(b && (b.pressed || b.value > AXIS_ON)));
1836
+ if (!s.armed) {
1837
+ if (!pressed.some(Boolean) && time - s.stillAt > 250) {
1838
+ s.armed = true;
1839
+ s.rest = s.still;
1840
+ }
1841
+ return;
1842
+ }
1843
+ const pos = SETUP_STEPS[s.step];
1844
+ let src = null;
1845
+ const button = pressed.indexOf(true);
1846
+ if (button >= 0 && !pos.startsWith('stick')) {
1847
+ src = { b: button };
1848
+ }
1849
+ for (let i = 0; !src && i < axes.length; i++) {
1850
+ const rest = s.rest[i] ?? 0;
1851
+ const move = axes[i] - rest;
1852
+ const sign = move > 0 ? 1 : -1;
1853
+ if (Math.abs(move) <= 0.6) {
1854
+ continue;
1855
+ }
1856
+ // A D-pad that reports as one hat axis rests outside the range of a stick and gives
1857
+ // each direction a value of its own, which is stored as it is.
1858
+ if (Math.abs(rest) > 1.05 || (DIRECTIONS.has(pos) && Math.abs(axes[i]) < 0.9)) {
1859
+ src = { a: i, v: axes[i] };
1860
+ } else if (sign * axes[i] > AXIS_ON) {
1861
+ src = { a: i, s: sign, t: AXIS_ON };
1862
+ }
1863
+ }
1864
+ if (src) {
1865
+ s.layout[pos] = src;
1866
+ this.setupStep(s.step + 1);
1867
+ }
1868
+ }
1869
+
1870
+ // The marker, an arrow in the team's color over each person's player.
1871
+
1872
+ makeMarker() {
1873
+ const group = new THREE.Group();
1874
+ group.visible = false;
1875
+ this.arrows = [0, 1].map(() => {
1876
+ const geometry = new THREE.ConeGeometry(0.28, 0.6, 16);
1877
+ geometry.rotateX(Math.PI);
1878
+ const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color: 0xffffff, depthTest: false, transparent: true, opacity: 0.95 }));
1879
+ mesh.renderOrder = 10;
1880
+ group.add(mesh);
1881
+ return mesh;
1882
+ });
1883
+ this.app.view.scene.add(group);
1884
+ return group;
1885
+ }
1886
+
1887
+ // The frame rate of the picture while a game plays, which offers a lower quality once for each
1888
+ // visit below the rate at which Watch offers the 2D view, and never after it was declined.
1889
+ measureFrames(on) {
1890
+ const now = performance.now();
1891
+ const dt = this.slowAt ? (now - this.slowAt) / 1000 : 0;
1892
+ this.slowAt = now;
1893
+ if (!on || this.game.paused || document.hidden || dt <= 0 || dt > 1) {
1894
+ this.slowFrames = 0;
1895
+ this.slowSeconds = 0;
1896
+ return;
1897
+ }
1898
+ this.slowFrames = (this.slowFrames || 0) + 1;
1899
+ this.slowSeconds = (this.slowSeconds || 0) + dt;
1900
+ if (this.slowSeconds < SLOW_WINDOW) {
1901
+ return;
1902
+ }
1903
+ const fps = this.slowFrames / this.slowSeconds;
1904
+ this.slowFrames = 0;
1905
+ this.slowSeconds = 0;
1906
+ let declined = false;
1907
+ try {
1908
+ declined = localStorage.getItem(SLOW_DECLINED) === 'true';
1909
+ } catch {
1910
+ // Without storage the offer shows once for this visit.
1911
+ }
1912
+ if (fps >= SLOW_FPS || this.slowOffered || declined || this.app.settings.quality === 'low'
1913
+ || !offersView2d(location.search)) {
1914
+ return;
1915
+ }
1916
+ this.slowOffered = true;
1917
+ const rate = Math.max(1, Math.round(fps));
1918
+ this.slowText.replaceChildren(...tn('play.slow.text', { count: rate }));
1919
+ this.slowOffer.hidden = false;
1920
+ }
1921
+
1922
+ placeMarker(pose, s) {
1923
+ const game = this.game;
1924
+ const on = !!(game && game.started && !game.ended && this.app.source === game.source && this.app.page === 'play' && s.a);
1925
+ this.measureFrames(on);
1926
+ this.marker.visible = on;
1927
+ if (!on) {
1928
+ return;
1929
+ }
1930
+ const header = game.source.header;
1931
+ const bob = Math.sin(performance.now() / 180) * 0.08;
1932
+ for (let side = 0; side < 2; side++) {
1933
+ const arrow = this.arrows[side];
1934
+ // On a side shared with a registered team the frames mark every player of its agents,
1935
+ // so the game names the person's own player.
1936
+ const own = game.entry && game.entry.controlled ? game.entry.controlled[side] : null;
1937
+ const k = !game.humans[side] ? -1 : typeof own === 'number'
1938
+ ? header.players.findIndex((p) => p.team === side && p.index === own)
1939
+ : header.players.findIndex((p, i) => p.team === side && (s.a.playerFlags[i] & 8));
1940
+ arrow.visible = k >= 0;
1941
+ if (k < 0) {
1942
+ continue;
1943
+ }
1944
+ const style = this.app.styles && this.app.styles[side];
1945
+ if (style) {
1946
+ const c = style.kit.primary;
1947
+ arrow.material.color.setRGB(c[0] / 255, c[1] / 255, c[2] / 255, THREE.SRGBColorSpace);
1948
+ }
1949
+ const o = k * PLAYER_FLOATS;
1950
+ arrow.position.set(pose.nodes[o], 2.55 + bob, -pose.nodes[o + 1]);
1951
+ }
1952
+ }
1953
+ }