mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 20:12:03 +00:00
Compare commits
627 Commits
aa049aaff4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c02aad66a0 | |||
| 076d27a564 | |||
| 00b900d864 | |||
| fe453ab607 | |||
| 52cc478b38 | |||
| 4ea72be581 | |||
| 4560d2de8a | |||
| 4912837dd7 | |||
| aac00f148e | |||
| 52ee181042 | |||
| 4f48f0a6a8 | |||
| 0de97381b7 | |||
| ca70568fad | |||
| 8aa4af3a3a | |||
| 9ab1bec89a | |||
| 14da286e11 | |||
| fc2f5c8669 | |||
| 432e5a11e8 | |||
| 707aea5898 | |||
| 8ba045063d | |||
| 654f20e28f | |||
| 1becfb4f3f | |||
| 61a073099d | |||
| a1f30f6af9 | |||
| 8033d52db3 | |||
| 2702e53068 | |||
| 8b9ae35b43 | |||
| a4b362cb01 | |||
| 0a8f3924d0 | |||
| ccf7d0fbfe | |||
| f628ccfd35 | |||
| d40344a2c0 | |||
| 801fca7cb0 | |||
| 5765532409 | |||
| b8bcc1f3c1 | |||
| 5453e19761 | |||
| 129b0c7ef0 | |||
| 4248e51c60 | |||
| 320ec46ba2 | |||
| f6a87463c5 | |||
| 1dd05c75f1 | |||
| 2c648514ba | |||
| 6983ddd7df | |||
| 7d247bc516 | |||
| 089c127cc3 | |||
| b43ad207c1 | |||
| de263f30e8 | |||
| ad9289fb21 | |||
| ae4a6f937f | |||
| 7d50612abb | |||
| 24620be5c1 | |||
| 4c61b1e28d | |||
| 8810bf7d8f | |||
| 91b3fc938c | |||
| 79ab0d1404 | |||
| 5190cded56 | |||
| f09ef7da8f | |||
| ce17a45afb | |||
| 817572a6ce | |||
| a5cbba8ac9 | |||
| 482d4b0985 | |||
| f7958f3102 | |||
| d64920b0f9 | |||
| e6733bd6cb | |||
| 0acf144f45 | |||
| 61daf139c5 | |||
| 4e1dd0d24e | |||
| 3f3dada35f | |||
| 9a724bf562 | |||
| ea1c65acfb | |||
| 8507472635 | |||
| 759dbe2b65 | |||
| f50264dae6 | |||
| 6632cdace7 | |||
| b5b6bdea95 | |||
| 2050acd63d | |||
| 3b523cf525 | |||
| 854d160f27 | |||
| 25cf1e0cfa | |||
| addcea9fed | |||
| bcc2639a33 | |||
| a4de140424 | |||
| 947fefc95c | |||
| bd93a2657e | |||
| 2463713cde | |||
| aac81c89b6 | |||
| 3e0022ce9c | |||
| 2e9da3032c | |||
| 8c28374eb4 | |||
| cee0163eac | |||
| 4519f2db82 | |||
| 0f1a7403e4 | |||
| bef71e1dcf | |||
| a15368ed29 | |||
| 1976c6eac6 | |||
| eaf7ea8748 | |||
| f8af212e3f | |||
| aa446cfbfe | |||
| 781cbc35aa | |||
| 51f8008a38 | |||
| 1bce603c33 | |||
| cbefa86c5c | |||
| 6ebd6e59c1 | |||
| d59e0017f7 | |||
| 0bca441ca1 | |||
| 670466dbd7 | |||
| 55b88a3aa5 | |||
| 91658fbc13 | |||
| 84d27d82da | |||
| 3e9f3f2c4e | |||
| 4353abfd97 | |||
| f5fd3bb207 | |||
| 5dc659007e | |||
| 19ef84c3cb | |||
| fcb38b3d57 | |||
| 510138eb0a | |||
| b883338396 | |||
| a9b8f53ef1 | |||
| b15d19eec2 | |||
| 9b14109727 | |||
| 870b89d279 | |||
| bff71bfc2a | |||
| bfaf5d40ff | |||
| 8a55c33666 | |||
| 6a9b269798 | |||
| badd0b1b47 | |||
| f7ab77dec5 | |||
| ad6f9cc148 | |||
| fc3a7ea359 | |||
| ffc7856993 | |||
| 807aaa4478 | |||
| 3bd2387dc3 | |||
| edd78d2548 | |||
| bda3fc5aff | |||
| e9ed8923c9 | |||
| 56e8e2554c | |||
| 75f9026ea1 | |||
| e376e1e80d | |||
| 2bdf876b47 | |||
| dac414274e | |||
| c580e46125 | |||
| 03bc723b70 | |||
| a10c6d47f9 | |||
| 3985b74bcf | |||
| ec0bc362cd | |||
| d819658ace | |||
| 4d8638e62f | |||
| 82691eaf80 | |||
| 07fd0fde44 | |||
| 889e30a434 | |||
| 27017043f9 | |||
| 429fb87c08 | |||
| fd1a4d9577 | |||
| 17e6a9bc20 | |||
| 87d43302e1 | |||
| 73e5d64ba2 | |||
| 7534d8436c | |||
| 1f05f6d524 | |||
| cfc82bcea5 | |||
| 1e5825b096 | |||
| c08c761af3 | |||
| 0c4ad6a5aa | |||
| 277ad4bf98 | |||
| 0ce2a49419 | |||
| a60c1a097e | |||
| f491725144 | |||
| 0f1864f8bc | |||
| 30a5a89164 | |||
| 6ac2d0fbb1 | |||
| fa2c93ec06 | |||
| 47aa196b59 | |||
| 51f6e19339 | |||
| 9240cd4b27 | |||
| 950e879861 | |||
| d3a457d8d0 | |||
| eb3b685af0 | |||
| 3151e54ab3 | |||
| 96f311c129 | |||
| 836cedec3c | |||
| 614b87f7e1 | |||
| 30a22c366b | |||
| 3eb47bb9d3 | |||
| 701d7a9a2e | |||
| bfeb822279 | |||
| 2ff348adf0 | |||
| d4bde67e0f | |||
| 0666d8d308 | |||
| e83f84f527 | |||
| b01db32908 | |||
| caa7e3e793 | |||
| bd617dbf06 | |||
| ff80ab46b7 | |||
| 84372204fd | |||
| 5630c5c8dc | |||
| 52e3d73678 | |||
| ad59c5f567 | |||
| 957bb65a26 | |||
| 455055c67c | |||
| da92be73ef | |||
| 515b06d97c | |||
| b110bfc5f7 | |||
| b4d2dd8a9f | |||
| bb9f25ee8d | |||
| 81346ac149 | |||
| e1abac1271 | |||
| aa97259165 | |||
| 9cc68d7707 | |||
| 6366b5e1f6 | |||
| d04523accd | |||
| 05e8a1b398 | |||
| e56850a236 | |||
| 066aee96cc | |||
| e78f805c92 | |||
| 34cc994598 | |||
| 398d6ade61 | |||
| 2e1c010fa2 | |||
| 95e82cc719 | |||
| 9a72dd5eab | |||
| fc000e71d5 | |||
| 4a1a6f6697 | |||
| 72e8d27633 | |||
| 55706ba9ea | |||
| 71935e61b5 | |||
| 25236cc0a1 | |||
| 4b243f1a47 | |||
| 181a928c87 | |||
| c4c2ada1f6 | |||
| 76c1c3d600 | |||
| 7c044b7094 | |||
| e3fad7064b | |||
| c65cd2d17e | |||
| 3317574bf2 | |||
| bf396afcaf | |||
| b72a7cf843 | |||
| 063dff463d | |||
| 00d6b1edd3 | |||
| a57b582cf1 | |||
| b33f681ffa | |||
| b1783abdce | |||
| 88eb510dc3 | |||
| 8129bb2571 | |||
| 6042c3b154 | |||
| 9b76d47c52 | |||
| e934bbfe44 | |||
| e376675fa6 | |||
| 7170400f49 | |||
| 4c1ed87344 | |||
| 7b2f9c26f4 | |||
| 9004800326 | |||
| 533ac1afab | |||
| dc901f5951 | |||
| 67da422ea6 | |||
| 0bf33e7fe8 | |||
| 20f376f713 | |||
| d0c96422b2 | |||
| 65f0ad5dfd | |||
| 98ff2aed81 | |||
| 0787702f23 | |||
| f1b9527d3f | |||
| c5f6c95ca5 | |||
| d1bcb51225 | |||
| f1b8366531 | |||
| d85c8b8194 | |||
| 6ef55affcb | |||
| 9ae01ecc5a | |||
| 8bd1455a2c | |||
| 315c524c42 | |||
| 75cb8faac4 | |||
| cc12260225 | |||
| 1d926e705b | |||
| 4e88ea80f3 | |||
| b24f9fc448 | |||
| 8c2dc66c7c | |||
| 334d8a26ac | |||
| 74e50caf70 | |||
| d15d16d593 | |||
| ec82367c50 | |||
| a6d2bdf8bd | |||
| bae7458668 | |||
| b7642ad2be | |||
| 3dbecb0bd5 | |||
| a439a1059b | |||
| 80d6ee8cf5 | |||
| 69a8402f11 | |||
| 5812386676 | |||
| e62909d457 | |||
| 12b9712a8d | |||
| b4ea50d76a | |||
| bdb3c8f4a7 | |||
| 498f219ab9 | |||
| c0861bfcad | |||
| 68e76b5feb | |||
| 863cf61f1a | |||
| 802e5fc96f | |||
| f3b56f70e3 | |||
| 9ec707674a | |||
| 207ab47866 | |||
| dad26a164c | |||
| 04b5d3b29d | |||
| d081a72b9a | |||
| f096e8ff0b | |||
| 79a6092b28 | |||
| aa93aeec95 | |||
| 1e1db3525e | |||
| 544f76c502 | |||
| d588898f5d | |||
| 939b7a9584 | |||
| 520613aab0 | |||
| 330f99bb0e | |||
| 79318b56bd | |||
| fe0a0b72a8 | |||
| 4627dd58fb | |||
| c8c363e667 | |||
| 6237a25a69 | |||
| 801a8487f5 | |||
| 3168dd9897 | |||
| 01bb9a9431 | |||
| 57fe6f4acc | |||
| a1ae36a54c | |||
| 8a9972b6fc | |||
| f7657ad9ad | |||
| 6edaedb50d | |||
| 521b8122ac | |||
| 8fa53b778c | |||
| e5c4e0b89a | |||
| 77c04a57a9 | |||
| 80a47d850e | |||
| bf160e4237 | |||
| fc2faf9723 | |||
| d8245047a9 | |||
| c0a1ef94f0 | |||
| 21c57a6d22 | |||
| 4cad0f0cce | |||
| 4c2ade3930 | |||
| 14e1e62deb | |||
| 9b26f867bc | |||
| fd18cf6ac0 | |||
| 4278c04d60 | |||
| 0bad9e07db | |||
| 8b76f80b5c | |||
| 270ab00c52 | |||
| d9e806ed27 | |||
| 17bd7768eb | |||
| bf64cce947 | |||
| caece00e7f | |||
| dbdfaa5d9e | |||
| 42ac34ca70 | |||
| ea6939e18d | |||
| 63eb43d50a | |||
| e9d9f59a6a | |||
| 4eaa3304c3 | |||
| 1490ff7fcf | |||
| 3817df2a12 | |||
| 06a4ea0a02 | |||
| 66d114bfe3 | |||
| 6d3490da14 | |||
| e23243ff56 | |||
| 67609d71c0 | |||
| 7e5cfdeceb | |||
| 99df7e20e0 | |||
| d937cb153c | |||
| 4fb7ddfecf | |||
| 9dc1cc2d6f | |||
| 013eb0778b | |||
| 25fdc2c2c8 | |||
| 55c46f56ec | |||
| 6f7d61eafb | |||
| 03ff8e485e | |||
| e0ba6c6ead | |||
| 7807b9706b | |||
| 11599889fa | |||
| 2d750cbcab | |||
| d882469c79 | |||
| 7a3d520608 | |||
| dd80b52a11 | |||
| 388300c553 | |||
| eebab1bc19 | |||
| a70a0ebc74 | |||
| febc69bdef | |||
| 8b5b5333c6 | |||
| 55e07648cf | |||
| 0023bdab6e | |||
| 931e51a647 | |||
| 4bbaf0976f | |||
| e729570010 | |||
| b1966a3423 | |||
| 7803e1ec7c | |||
| def60169a8 | |||
| 25cc182793 | |||
| e170bcf0ef | |||
| d0952adaf9 | |||
| bdc89303b4 | |||
| 18538e833b | |||
| fe9f6f3cb5 | |||
| 18888ed520 | |||
| 4d83ec1525 | |||
| 58508bd87c | |||
| e7c835af52 | |||
| 6caf719158 | |||
| 5b80c97337 | |||
| bd26aa3dc4 | |||
| 9d210d254a | |||
| c8a542a3af | |||
| eadaa7b54e | |||
| 414df530f0 | |||
| 5234390931 | |||
| eb8eecf080 | |||
| 3aad68a6e6 | |||
| e25d61d80e | |||
| 66a1ee8007 | |||
| 172775c141 | |||
| 4b01b7fc88 | |||
| 68d832f7bc | |||
| d8ea2f4ac7 | |||
| 82bb9baec6 | |||
| 9848f4b92d | |||
| aeb37a4c6e | |||
| afcb01d155 | |||
| c5678ce877 | |||
| 76aad61191 | |||
| 467c25b20c | |||
| 0cdb60c0d9 | |||
| 23134796ee | |||
| fec4f91672 | |||
| 5b40bf9066 | |||
| 63f4f11aa8 | |||
| f520584368 | |||
| eef7cf28da | |||
| 30b4560bd5 | |||
| 1eb6e8f9f4 | |||
| 3ae2daec88 | |||
| 7f7516d9a0 | |||
| 3533e8ae6c | |||
| fe246bf54e | |||
| 4b82347d43 | |||
| 60fe2caf8f | |||
| 161d2cdceb | |||
| a4bc8cdac8 | |||
| 0be51ab902 | |||
| a16db39884 | |||
| d258f17852 | |||
| d77563147c | |||
| fcc5d82763 | |||
| 6b3db98327 | |||
| 69f1ef6be1 | |||
| 99680dbf6f | |||
| 585056ccc3 | |||
| e1807d2492 | |||
| 3e94960f0a | |||
| 222bbb5b84 | |||
| fddbebb33b | |||
| 62c1478913 | |||
| 66a67c931d | |||
| 550d73f1d7 | |||
| 47550aefae | |||
| 5106ac64da | |||
| 39ebfce1bb | |||
| 2379c154a5 | |||
| 5265b1738b | |||
| 4d3a8cc217 | |||
| 8253d772cb | |||
| a2a7107dd7 | |||
| b3284d4bd6 | |||
| ae164e625a | |||
| 9fe6c57b5d | |||
| 1a43a342ea | |||
| 59cf29949f | |||
| b2d68d93cf | |||
| 068b66b234 | |||
| e37a519ef8 | |||
| a45d2ddbb7 | |||
| 4c131db2ca | |||
| 948d603c0e | |||
| 9263133e64 | |||
| 3b208ae860 | |||
| b47c7dc3fa | |||
| b1314074a8 | |||
| 52a96de8d6 | |||
| 72d4a604c2 | |||
| 90126a4be7 | |||
| b2ee9ec92d | |||
| 0551fb0b1f | |||
| 6253b620a9 | |||
| faede927fc | |||
| 307828ff7f | |||
| 7b6e22292a | |||
| 4b5e40bff7 | |||
| bf4da9fd39 | |||
| 229ded8613 | |||
| 4dcf98cbb0 | |||
| 02a11704ce | |||
| 91e536425d | |||
| 0f1cc17af6 | |||
| 236cca30ba | |||
| 846663e320 | |||
| 726fe1ce2e | |||
| 88b5ffedb2 | |||
| 79e66c7a95 | |||
| 8d1b407bb0 | |||
| 77c01dc531 | |||
| cd30098eca | |||
| caa875f15c | |||
| 58e8a5c523 | |||
| bc7136b2cb | |||
| e13a64756f | |||
| 63332435d2 | |||
| 4e66c5758c | |||
| 723f8aea5d | |||
| c0d1c33f54 | |||
| 3dde0ffb79 | |||
| 0364d3f172 | |||
| 1902084523 | |||
| bbabd259b6 | |||
| 217ae263cd | |||
| 3b5f50023b | |||
| 518df3a73a | |||
| 893db17c03 | |||
| 7ce28559d2 | |||
| b1b4608dd8 | |||
| e702661388 | |||
| dfe8d46d99 | |||
| 9c6d48ae2c | |||
| 81e68bb5fa | |||
| 2b8bce5e4b | |||
| 9810ee543f | |||
| a0195987bd | |||
| 698413cd91 | |||
| ecc78b7a2a | |||
| a616b7637e | |||
| 7c4b64b50a | |||
| 637b522486 | |||
| 864e4e8aaf | |||
| b04f3318b9 | |||
| cc2cd80a01 | |||
| 1793eb7621 | |||
| 7643dbc439 | |||
| cf212d94f9 | |||
| 997175c753 | |||
| 6c0163c3ec | |||
| b79d358db9 | |||
| 07fe144b2f | |||
| f5d9c08468 | |||
| e3119bf77c | |||
| 4264a2bbd3 | |||
| d864ce2475 | |||
| 15fdd989e2 | |||
| 5b8638e15e | |||
| f3e7538fb7 | |||
| af8592082e | |||
| 62ee3f2777 | |||
| 835233672f | |||
| 8d4a0640e2 | |||
| bcc12aad19 | |||
| 4ffa1543cc | |||
| 3aa0f5b9c2 | |||
| 964094f65a | |||
| c51d5ee369 | |||
| 6d837b2bf3 | |||
| 4f13b4eca9 | |||
| b946f78d1f | |||
| ca17265bf1 | |||
| d235342889 | |||
| 706ac9bd2c | |||
| 5de72b0636 | |||
| 295be3d26a | |||
| 3af7ed077c | |||
| e364a7dd06 | |||
| d06a67ade6 | |||
| 6d94366693 | |||
| cb06300685 | |||
| 08eb9f5842 | |||
| e1f512c94e | |||
| 6320b982a8 | |||
| 46fe696a58 | |||
| dffc2812e1 | |||
| 614ec9cda9 | |||
| 17f588b95b | |||
| 7c949d8679 | |||
| ba1887fe93 | |||
| b83e030a44 | |||
| 5fcdb256b3 | |||
| b69291a7d3 | |||
| f01b1c3cbb | |||
| b99f63afb7 | |||
| 04865abb39 | |||
| d551ff9cce | |||
| 24d6a547d6 | |||
| 3649620726 | |||
| 23c1c3d231 | |||
| 72944a03e9 | |||
| dab647e514 | |||
| 9e4609d5b3 | |||
| 6bafdc7794 | |||
| f2b72394de | |||
| ec896b27ac | |||
| 06881f05ca | |||
| 624d1c6b78 | |||
| ff725e1ffa | |||
| 5714829c13 | |||
| 866efa0d9b | |||
| e51dc765a2 | |||
| 818f8e89cd | |||
| 23e3dd18f9 | |||
| 0699b14d4e | |||
| 7a1668c902 | |||
| b5e9dff33c | |||
| a5cbc977b5 | |||
| 3d6906b981 | |||
| 9f28c02488 | |||
| 75f485667b | |||
| 9708bfafa3 | |||
| 12bc4d7e8a | |||
| 3d3024ae8a | |||
| cf73074e27 | |||
| 2325313ad2 | |||
| 10040f7339 | |||
| caa9f44ab6 | |||
| 9d8a8080ba | |||
| b290f49143 | |||
| 5bbb319161 | |||
| 86a597f0f5 | |||
| 14698d4ccb | |||
| 7b150ef72e | |||
| 39a41c016c | |||
| 4533da34e0 | |||
| e83bb4fa0c | |||
| 04691aaa48 |
@@ -0,0 +1,23 @@
|
|||||||
|
name: Agones Integration
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- Dockerfile
|
||||||
|
- Makefile
|
||||||
|
- deploy/k8s/**
|
||||||
|
- scripts/verify_kind_agones.sh
|
||||||
|
- .github/workflows/agones-integration.yml
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
kind-agones:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Verify disposable kind/Agones lifecycle
|
||||||
|
run: make verify-kind-agones
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Allocated Compose Smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- Dockerfile
|
||||||
|
- Makefile
|
||||||
|
- compose.allocated-smoke.yml
|
||||||
|
- server/api/**
|
||||||
|
- server/store/**
|
||||||
|
- server/workload/**
|
||||||
|
- server/migrations/**
|
||||||
|
- scripts/verify_allocated_compose.sh
|
||||||
|
- .github/workflows/allocated-compose.yml
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
allocated-compose:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Verify independent allocated Compose flow
|
||||||
|
run: make verify-allocated-compose
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: Dedicated Server Smoke Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dedicated-server-smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- name: Build and verify exported dedicated server
|
||||||
|
run: make verify-phase6
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: ENet Integration Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
enet-integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- name: Build the pinned Godot test image
|
||||||
|
run: docker build --target enet-test -t cosmic-clash-enet-tests .
|
||||||
|
- name: Run multi-process ENet smoke tests
|
||||||
|
run: docker run --rm cosmic-clash-enet-tests bash scripts/verify_enet_integration.sh
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Multiplayer Chaos Recovery
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- Dockerfile
|
||||||
|
- Makefile
|
||||||
|
- compose.chaos-smoke.yml
|
||||||
|
- server/cmd/maintenance/**
|
||||||
|
- server/store/**
|
||||||
|
- server/migrations/**
|
||||||
|
- scripts/verify_chaos_recovery.sh
|
||||||
|
- .github/workflows/multiplayer-chaos.yml
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api-restart-recovery:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Verify API restart and stalled-allocation recovery
|
||||||
|
run: make verify-chaos-recovery
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Multiplayer API Load
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- server/api/**
|
||||||
|
- server/domain/**
|
||||||
|
- server/matcher/**
|
||||||
|
- Makefile
|
||||||
|
- .github/workflows/multiplayer-load.yml
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api-load:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Verify 10,000-client API load boundary
|
||||||
|
run: make verify-multiplayer-load
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# The Go control plane is ~24k lines, and until this workflow existed the only
|
||||||
|
# Go tests CI ever ran were the two load tests in multiplayer-load.yml. Nothing
|
||||||
|
# else — domain policy, the wire/store boundaries, the allocator, the Steam
|
||||||
|
# adapter — gated a change. The Godot unit suite is covered (verify-phase6 runs
|
||||||
|
# test_runner.tscn as its first step); this closes the equivalent gap on the
|
||||||
|
# Go side.
|
||||||
|
#
|
||||||
|
# Deliberately Docker-free and cluster-free so it stays fast enough to gate
|
||||||
|
# every push. Tests that need a real PostgreSQL or Redis are behind the
|
||||||
|
# `integration` build tag and stay with their own scripts; `go vet` is still
|
||||||
|
# run over that tag so those files cannot rot uncompiled.
|
||||||
|
name: Server Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: server
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: server/go.mod
|
||||||
|
cache-dependency-path: server/go.sum
|
||||||
|
- name: Build
|
||||||
|
run: go build ./...
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
# Integration-tagged files are excluded from the default build, so
|
||||||
|
# without this a signature change could leave them broken until someone
|
||||||
|
# ran the integration scripts by hand.
|
||||||
|
- name: Vet integration-tagged tests
|
||||||
|
run: go vet -tags integration ./...
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
|
# The control plane is concurrent by design: outbox dispatchers, the
|
||||||
|
# event hub, the matcher worker and the allocator all run in parallel.
|
||||||
|
- name: Test with race detector
|
||||||
|
run: go test -race ./...
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Supply Chain Policy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
repository-policy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Verify immutable image references and secret hygiene
|
||||||
|
run: make verify-supply-chain
|
||||||
|
- name: Verify release process is documented
|
||||||
|
run: test -s docs/SUPPLY-CHAIN.md
|
||||||
@@ -17,5 +17,14 @@ training/checkpoints/*/ppo_*_steps.zip
|
|||||||
# export_linux.sh / run_training.sh), not a training result.
|
# export_linux.sh / run_training.sh), not a training result.
|
||||||
training/build/
|
training/build/
|
||||||
|
|
||||||
|
# Exported dedicated server binary (task 6.1): same reasoning — an 85MB
|
||||||
|
# regenerable artifact, rebuilt by `godot --headless --path Game
|
||||||
|
# --export-release "Linux Dedicated Server"`.
|
||||||
|
server/build/
|
||||||
|
|
||||||
|
# Steam exports and local App ID configuration are developer-machine inputs.
|
||||||
|
steam/build/
|
||||||
|
steam_appid.txt
|
||||||
|
|
||||||
# Texture generator scripts: throwaway env, not the scripts themselves.
|
# Texture generator scripts: throwaway env, not the scripts themselves.
|
||||||
tools/textures/.venv/
|
tools/textures/.venv/
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Investigate and fix the failing Agones Integration CI gate
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
`make verify-kind-agones` (workflow `.github/workflows/agones-integration.yml`,
|
||||||
|
script `scripts/verify_kind_agones.sh`) fails. Find the root cause and fix it so
|
||||||
|
the gate passes on CI. Repo: `jcreek/CosmicClash`, branch `feat/multiplayer`,
|
||||||
|
PR #30.
|
||||||
|
|
||||||
|
## What is already known — do not re-derive this
|
||||||
|
|
||||||
|
**The failure.** `helm upgrade --install agones ... --wait --timeout 5m` fails
|
||||||
|
with `Error: context deadline exceeded`. Immediately before, Helm reports:
|
||||||
|
|
||||||
|
```
|
||||||
|
resource Deployment/agones-system/agones-controller not ready. status: InProgress, message: Available: 0/1
|
||||||
|
resource Deployment/agones-system/agones-extensions not ready. status: InProgress, message: Available: 0/1
|
||||||
|
resource Deployment/agones-system/agones-allocator not ready. status: InProgress, message: Available: 0/1
|
||||||
|
```
|
||||||
|
|
||||||
|
So the cluster is created, the game-server image loads, and the Agones chart
|
||||||
|
installs — but none of its Deployments become Available inside 5 minutes. The
|
||||||
|
script never reaches the parts that exercise this repo's own manifests.
|
||||||
|
|
||||||
|
**It is pre-existing.** It fails identically at `089c127c`, the branch head
|
||||||
|
before recent work. It is not caused by the branch's changes. Do not assume a
|
||||||
|
recent commit broke it.
|
||||||
|
|
||||||
|
**It is not architecture-specific.** It fails the same way on GitHub's
|
||||||
|
`ubuntu-24.04` amd64 runners and on an arm64 macOS developer machine. Agones
|
||||||
|
1.49.0 publishes both amd64 and arm64 images.
|
||||||
|
|
||||||
|
**It is not a Helm kubeVersion rejection.** Agones charts 1.49.0, 1.50.0 and
|
||||||
|
1.51.0 declare no `kubeVersion` constraint, so Helm is not refusing the
|
||||||
|
Kubernetes version — the pods are being created and are not becoming ready.
|
||||||
|
|
||||||
|
**Ruled out as a red herring:** reproducing locally on a machine with heavy
|
||||||
|
Docker usage produced `FailedCreatePodSandBox: containerd connection reset`,
|
||||||
|
which is local resource pressure, not the CI cause. If you see that locally,
|
||||||
|
clear Docker state and retry rather than chasing it.
|
||||||
|
|
||||||
|
**There may be two distinct failures, not one.** After `docker system prune`,
|
||||||
|
a local run got *past* the Agones install cleanly (controller and allocator
|
||||||
|
both reached "condition met") and failed later, at:
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/verify_kind_agones.sh:146
|
||||||
|
kubectl wait --for=jsonpath='{.status.ready}'=2 fleet/cosmic-clash-game -n cosmic-clash --timeout=5m
|
||||||
|
error: timed out waiting for the condition on fleets/cosmic-clash-game
|
||||||
|
```
|
||||||
|
|
||||||
|
So locally the Agones install is fine and the **Fleet's game-server pods never
|
||||||
|
become Ready**; on CI the run never gets that far because the Agones install
|
||||||
|
itself times out. Treat these as potentially separate problems: fixing the CI
|
||||||
|
Agones timeout may simply expose the Fleet one underneath. Both need to pass.
|
||||||
|
|
||||||
|
The Fleet failure is the more suspicious of the two for recent work, because
|
||||||
|
`deploy/k8s/base/fleet.yaml` changed: the join-signing key material moved from
|
||||||
|
a single raw-bytes secret key (`join-signing-key`) to a JSON map
|
||||||
|
(`join-signing-keys.json`), and the mount's `items[].key` moved with it. The
|
||||||
|
script's `kubectl create secret` was updated to match and does succeed
|
||||||
|
(`secret/cosmic-clash-game-server created`), so the obvious mismatch is not
|
||||||
|
present -- but verify the pod actually mounts and starts rather than assuming.
|
||||||
|
Note the script's `sed` also strips `--allocated-mode` and the roster path and
|
||||||
|
blanks `--control-plane-url`, so the game server runs in a reduced mode here;
|
||||||
|
check whether it is failing for a reason unrelated to the key at all.
|
||||||
|
|
||||||
|
## Pinned versions (all in `scripts/verify_kind_agones.sh`)
|
||||||
|
|
||||||
|
| Thing | Value | Override |
|
||||||
|
|---|---|---|
|
||||||
|
| Agones chart | `1.49.0` | `AGONES_VERSION` |
|
||||||
|
| kind node image | `kindest/node:v1.33.1` (Kubernetes 1.33) | `KIND_NODE_IMAGE` |
|
||||||
|
| Cluster | single node, `--wait 120s` | `KIND_CLUSTER_NAME` |
|
||||||
|
| Runner | `ubuntu-latest` (ubuntu-24.04), 30 min timeout | — |
|
||||||
|
|
||||||
|
The chart is installed with `--set agones.controller.replicas=1`,
|
||||||
|
`agones.extensions.replicas=1`, `agones.allocator.replicas=1`, and
|
||||||
|
`agones.extensions.resources.{requests,limits}.ephemeral-storage` lowered to
|
||||||
|
128Mi/512Mi. That ephemeral-storage override already exists because Agones 1.49
|
||||||
|
otherwise requests 10,100 MiB and will not schedule on a default kind node —
|
||||||
|
there is a comment saying so. **A similar resource-fit problem for the other
|
||||||
|
Deployments is a strong hypothesis worth checking first.**
|
||||||
|
|
||||||
|
## Diagnostics are already in place
|
||||||
|
|
||||||
|
The script now dumps, on any failure and before the cluster is deleted: node
|
||||||
|
capacity and conditions, pods in `agones-system` and `cosmic-clash`, recent
|
||||||
|
events per namespace, and describe + current/previous logs for every not-ready
|
||||||
|
pod. Set `KIND_KEEP_ON_FAILURE=1` to retain the cluster for interactive
|
||||||
|
inspection instead of deleting it.
|
||||||
|
|
||||||
|
Its first run revealed a bug in the diagnostics themselves: a
|
||||||
|
`kubectl cluster-info` reachability guard suppressed the entire dump. That
|
||||||
|
guard has been removed, so the dump now always runs on failure.
|
||||||
|
|
||||||
|
**Start by reading that output**, either from a CI run or a local run. The most
|
||||||
|
likely candidates it will distinguish between:
|
||||||
|
|
||||||
|
1. **Resource pressure** — `FailedScheduling ... Insufficient cpu/memory/
|
||||||
|
ephemeral-storage`. Fix by lowering requests for the other Deployments the
|
||||||
|
way extensions already is, or by giving the kind cluster more capacity.
|
||||||
|
2. **Version incompatibility** — Agones 1.49 against Kubernetes 1.33. Check
|
||||||
|
Agones' release notes for its supported Kubernetes range; if 1.33 is outside
|
||||||
|
it, either raise `agones_version` or lower `kind_node_image`. Confirm the
|
||||||
|
pairing is one Agones actually tests.
|
||||||
|
3. **Probe/readiness failure** — pods Running but never Ready. The pod logs and
|
||||||
|
describe output will show the failing probe.
|
||||||
|
4. **Image pull** — `ImagePullBackOff` on an Agones image.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Do not weaken the gate to make it pass.** Removing `--wait`, extending the
|
||||||
|
timeout to hide a real failure, or `|| true` around the install are all wrong.
|
||||||
|
If the cause is genuinely a timeout on slow-but-working startup, raising it
|
||||||
|
is acceptable *only* with evidence that the pods do become Available, and the
|
||||||
|
new value should be justified in a comment.
|
||||||
|
- Keep it a disposable, isolated cluster: it must not touch an existing cluster,
|
||||||
|
and the EXIT trap must still remove the one it created.
|
||||||
|
- If you change a pinned version, pin the new one explicitly and say why in the
|
||||||
|
commit message. Do not float to `latest`.
|
||||||
|
- `CLAUDE.md` applies: never create co-authored commits, never mention Claude.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `make verify-kind-agones` passes locally (needs Docker, kind, kubectl, Helm).
|
||||||
|
- The `Agones Integration` workflow passes on PR #30. It is `pull_request`
|
||||||
|
triggered with path filters on `Dockerfile`, `Makefile`, `deploy/k8s/**`,
|
||||||
|
`scripts/verify_kind_agones.sh`, and its own workflow file — so a change to
|
||||||
|
the script will trigger it.
|
||||||
|
- Do not regress the other seven workflows. `Allocated Compose Smoke` was also
|
||||||
|
failing and has just been fixed; confirm it stays green.
|
||||||
|
|
||||||
|
## Useful context
|
||||||
|
|
||||||
|
- `multiplayer-next.md` §7 task 8.49 describes what this gate is meant to prove.
|
||||||
|
- `deploy/k8s/base/fleet.yaml` is the Fleet the script applies after Agones is
|
||||||
|
up, with a `sed` that swaps the release digest placeholder for the locally
|
||||||
|
built image and strips `--allocated-mode` and the roster path (there is no
|
||||||
|
control plane in this disposable cluster).
|
||||||
|
- The gate is a prerequisite for issue #17 (standing up a real cluster).
|
||||||
@@ -6,10 +6,26 @@ Important rule: never create co-authored commits. Never mention Claude in commit
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The project is GDScript/Godot only right now — the "C# backend" mentioned in README.md is planned but not yet started. There is no server-side code; the MVP is local-only play against bots.
|
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, a registry to publish images to, and a live Agones cluster; `TODO.md` orders them), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 1–6). See `multiplayer-next.md` for what actually remains.
|
||||||
|
|
||||||
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
|
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
|
||||||
|
|
||||||
|
### Where the documentation lives
|
||||||
|
|
||||||
|
The prose docs carry far more design rationale than the code comments, and several are load-bearing:
|
||||||
|
|
||||||
|
- `multiplayer-next.md` — **the multiplayer task-tracking document**: outstanding work (§0), a numbered "gotchas" list (§9), and the current task breakdown with checkboxes (§7), all in one file. Start at §0 for "what's left". Day-to-day work is Phase 7 (Steam) and Phase 8 (matchmaking), whose numbered task breakdown and acceptance criteria live in §7, because that is the format tasks are picked up from. Phases 0–6 are done and archival — their evidence lives in git history, not the current doc.
|
||||||
|
- `MULTIPLAYER_SPEC.md` — the architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget and match lifecycle state machine, as sections 1–6. Code comments across `Game/scripts/` cite it constantly by section number (`§2.4`, `§4.1`); many still say `multiplayer-next.md §N` for `N` 1–6 from before this doc was split out — when a comment does, the content is now here, not there. `multiplayer-next.md`'s own §7+ cites `§N` the same way and disambiguates by number (1–6 → this doc, 7+ → itself).
|
||||||
|
- `TRAINING.md` — the full RL workflow (training, curriculum generations, export, eval, difficulty tiers).
|
||||||
|
- `SERVER.md` — dedicated-server build, config, systemd deploy, sizing.
|
||||||
|
- `STEAM.md` — optional GodotSteam custom-build setup and the transport contract.
|
||||||
|
- `FLIGHT_MANUAL.md` — the player-facing flight model.
|
||||||
|
- `docs/MATCHMAKING.md` — casual/ranked queue design, and the locked constraints (Go/PostgreSQL/Redis/Agones) the `server/` module implements. Partially implemented; a 1.0 launch blocker, and the reason a backend service outside the Godot project exists at all.
|
||||||
|
- `docs/TECH_STACK.md` — what the project is built with and why, including the Go-vs-C#/Rust/C++ rationale for the matchmaking control plane.
|
||||||
|
- `TODO.md` — deferred non-multiplayer work, **and** the ordered human-actionable backlog: which GitHub issue to do first, what each one unblocks, and which items are waiting on nobody. Start there when asking "what next". Audio is no longer absent — a procedural `AudioManager` covers UI, countdown, impact, goal and engine/turbo cues; what remains is authored assets.
|
||||||
|
- `docs/THREAT-MODEL.md`, `docs/SUPPLY-CHAIN.md`, `docs/OBSERVABILITY.md`, `docs/MATCHMAKING-SLOs.md`, `docs/ADR-001-matchmaking-platform.md` — the control plane's security, release, telemetry and SLO contracts. `server/security/*.py` asserts several of them against the checked-in manifests, so changing a manifest often means changing one of these.
|
||||||
|
- `docs/REVIEW-2026-09-feat-multiplayer.md` — a point-in-time adversarial review of this branch. Every finding in it is fixed; it is kept for the reasoning, not as a status report, and its header says so.
|
||||||
|
|
||||||
## Godot MCP server
|
## Godot MCP server
|
||||||
|
|
||||||
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/godot-mcp) as a git submodule at `mcp/godot-mcp` and registers it in `.mcp.json`. **Prefer the godot-mcp tools over manual file edits or shell commands** when the task involves inspecting or modifying the Godot project — reading/editing scenes, nodes, scripts, running the project, or interacting with a live Godot editor/runtime instance. It understands Godot's scene tree and `.tscn`/`.gd` structures directly, which is more reliable than hand-parsing them.
|
This repo vendors [godot-mcp](https://github.com/tugcantopaloglu/godot-mcp) as a git submodule at `mcp/godot-mcp` and registers it in `.mcp.json`. **Prefer the godot-mcp tools over manual file edits or shell commands** when the task involves inspecting or modifying the Godot project — reading/editing scenes, nodes, scripts, running the project, or interacting with a live Godot editor/runtime instance. It understands Godot's scene tree and `.tscn`/`.gd` structures directly, which is more reliable than hand-parsing them.
|
||||||
@@ -53,34 +69,234 @@ Upstream ships telemetry, and there are **two independent switches** — turning
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
There is no build step, linter, or automated test suite for the GDScript project itself — Godot projects run directly from source.
|
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
|
||||||
|
|
||||||
|
### Running the game
|
||||||
|
|
||||||
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
|
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
|
||||||
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
|
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
|
||||||
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
|
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
|
||||||
|
- **Import resources first** on a fresh checkout or in a container: `godot --headless --path Game --import`. Do **not** add `--quit` — it ends the editor after one iteration and can leave `.godot/imported` half-generated (see the Dockerfile comment).
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
`godot --headless --path Game res://tests/test_runner.tscn` — pure-function assertions, exits 0/1.
|
||||||
|
|
||||||
|
Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed. The same path-vs-`class_name` caveat is why `net_codec.gd`, `sim_constants.gd` etc. are reached by `preload()` elsewhere in the codebase.
|
||||||
|
|
||||||
|
The runner has **no filter flag** — it always runs everything (it's fast). To isolate one case, temporarily move the others out of `tests/cases/`.
|
||||||
|
|
||||||
|
Two runner behaviours exist because of past silent-pass bugs, and new tests must respect them: a case file that fails to parse is a failure (`can_instantiate()` is the guard — `load()` does *not* return null on a broken script), and **a test that completes having made zero assertions is itself a failure**, because GDScript has no exceptions and a crash before the first `assert_*` would otherwise read as a pass.
|
||||||
|
|
||||||
|
### Networking smoke tests (multi-process)
|
||||||
|
|
||||||
|
These need two or three real `godot --headless` processes for a live ENet handshake, so they are **not** part of `test_runner.tscn`. Each prints `SMOKE PASS/FAIL: ...` and exits 0/1. Run them all through the wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make verify-enet-integration # host+client pairs for every case, then the 3-process match
|
||||||
|
VERIFY_ENET_CASES=net,clock make verify-enet-integration # subset, for local debugging
|
||||||
|
GODOT_BIN=/path/to/godot make verify-enet-integration # non-default Godot binary
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/verify_enet_integration.sh` starts each role, waits, and fails on any `SCRIPT ERROR`/`ERROR:`/`SMOKE FAIL` in the logs — a clean exit code alone is not the bar. It prints its temp log directory and dumps the logs on failure. The individual scenes, if you need to drive one by hand with `-- --role=<role>`:
|
||||||
|
|
||||||
|
| Scene | Roles | What it proves |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/net_smoke.tscn` | `host`, `client` | Raw connect/disconnect; the host observes `client_disconnected`, not just clean self-exit. |
|
||||||
|
| `tests/match_net_smoke.tscn` | `host`, `client`, `client-badversion`, `client-longname`, `host_recycle` | Handshake gating (protocol version, oversized name rejection) and that a host→leave→re-host cycle actually empties the roster (run a plain `client` against `host_recycle`). |
|
||||||
|
| `tests/clock_smoke.tscn` | `host`, `client` | Clock convergence, cross-checked against independent OS-wall-clock ground truth rather than self-consistency. |
|
||||||
|
| `tests/lobby_smoke.tscn` | `host`, `client` | **Both** roles load `lobby.tscn` for real via `change_scene_to_file`, exercising the server's read-only view as well as the client's interactive one. |
|
||||||
|
| `tests/networked_match_ci.tscn` | `host`, `client-bot --test-bot` (×2) | A full server + two AI-driven clients playing a real match: snapshot throughput and cross-peer score agreement, via a deterministic server-forced goal. |
|
||||||
|
| `tests/networked_match_smoke.tscn` | see its header | Shorter attended variant of the above. |
|
||||||
|
| `tests/net_sim_smoke.tscn` | see its header | The `--net-sim-*` latency/loss decorator actually changes observed behaviour. |
|
||||||
|
|
||||||
|
See `network_manager.gd`'s header comment and `multiplayer-next.md` §9 gotchas 25–30 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
|
||||||
|
|
||||||
|
**`main_menu.tscn`'s Host/Join flow** is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
|
||||||
|
|
||||||
|
`tests/server_physics_parity.gd` is a standalone `SceneTree` script (no `.tscn`, no wired runner) that traces the real Ship scene through fixed inputs and dumps every physics step's pose/velocity plus the observation vector. It exists to be diffed against the same file run from a `git archive HEAD` tree, proving a client-side change didn't perturb shared physics.
|
||||||
|
|
||||||
|
### Dedicated server (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make verify-phase6 # the whole exported-server gate; also the entire Phase 6 CI workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the stripped `Linux Dedicated Server` export, runs it in one container, joins two independent headless clients from two others, forces a server-owned goal in each of two matches, and asserts both clients saw both scores **and that the arena actually rotated between matches**. It needs several GB of free Docker space (the pinned `barichello/godot-ci:4.7.1` image is ~2.4 GB) and always tears down its Compose containers, printing its log directory either way.
|
||||||
|
|
||||||
|
The Dockerfile's targets are worth knowing: `project-imported` (base, resources imported) → `enet-test` (source client, used by the ENet CI workflow) → `exporter` (rewrites `run/main_scene` and exports the server) → `server` (slim Ubuntu runtime) and `smoke-client` (test-only harness). **Godot dedicated exports refuse command-line scene overrides**, which is why the server scene is baked in by `sed` at export time rather than passed as an argument.
|
||||||
|
|
||||||
|
To run one by hand, and for every config flag, see `SERVER.md`. `--smoke-force-goal-after=<seconds>` is a verification-only switch and must never be used for a real match.
|
||||||
|
|
||||||
|
### Steam builds (optional)
|
||||||
|
|
||||||
|
`make verify-steam-templates` requires custom GodotSteam executables pinned in `steam-dependencies.lock.json` and pointed at by `COSMIC_CLASH_STEAM_CLIENT_GODOT` / `COSMIC_CLASH_STEAM_SERVER_GODOT`. It deliberately refuses a stock Godot binary. Nothing else in the repo needs Steam — the default build and every Docker check are ENet-only. See `STEAM.md`.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
`.github/workflows/` has eight jobs, all but one running a Make target:
|
||||||
|
|
||||||
|
| Workflow | Runs | Needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `server-unit-tests.yml` | `go build`/`vet`/`vet -tags integration`/`test`/`test -race` in `server/` | nothing (no Docker) |
|
||||||
|
| `dedicated-server-smoke.yml` | `make verify-phase6` | Docker, several GB |
|
||||||
|
| `enet-integration.yml` | `make verify-enet-integration` inside the `enet-test` image | Docker |
|
||||||
|
| `allocated-compose.yml` | `make verify-allocated-compose` | Docker Compose |
|
||||||
|
| `agones-integration.yml` | `make verify-kind-agones` | kind + Helm |
|
||||||
|
| `multiplayer-chaos.yml` | `make verify-chaos-recovery` | Docker |
|
||||||
|
| `multiplayer-load.yml` | `make verify-multiplayer-load` (two `-tags load` Go tests) | — |
|
||||||
|
| `supply-chain.yml` | `make verify-supply-chain` | — |
|
||||||
|
|
||||||
|
**The Godot unit suite runs via `verify-phase6`**, which invokes `test_runner.tscn` as its first step — there is no separate Godot workflow. The Go unit suite has its own workflow because until it existed the only Go tests CI ran were `multiplayer-load`'s two load tests, so ~24k lines of control plane gated nothing.
|
||||||
|
|
||||||
|
Note what is *not* in CI: `make verify-multiplayer-local` (the combined local gate, which also runs the Python manifest/contract suites) and the `integration`-tagged Go tests, which need a real PostgreSQL/Redis and live in `scripts/run_*_integration.sh`. Run those by hand before landing server changes.
|
||||||
|
|
||||||
|
### When a gate fails, suspect the assertion first
|
||||||
|
|
||||||
|
The most expensive failures in this repo have not been broken behaviour. They
|
||||||
|
have been **assertions that cannot distinguish the two states they implicitly
|
||||||
|
claim to**, each reporting its own ambiguity as a confident verdict about the
|
||||||
|
system under test. Five in one session, several costing multiple CI round trips:
|
||||||
|
|
||||||
|
| Assertion | What it actually conflated |
|
||||||
|
|---|---|
|
||||||
|
| `kubectl wait --for=jsonpath='{.status.ready}'` on an Agones Fleet | field does not exist vs. condition unmet — it could never pass |
|
||||||
|
| `compose ps --status running \| grep -qx game-server` | not started *yet* vs. exited |
|
||||||
|
| `remote_residual_position_p99 < 0.3` | real regression vs. host scheduling noise |
|
||||||
|
| a validator reading `status.gameServer` | Agones' real response vs. an invented one, with unit tests asserting the invention |
|
||||||
|
| `docker image inspect` guarding a build | image is current vs. image merely exists, so a rerun verified stale code |
|
||||||
|
|
||||||
|
Before theorising about the code, ask: **can this check tell "broken" from
|
||||||
|
"not ready yet", "absent" from "unset", or "regressed" from "slow"?** If not,
|
||||||
|
that is the bug, whatever else is also true.
|
||||||
|
|
||||||
|
Two habits follow from it, and both repeatedly beat reading code:
|
||||||
|
|
||||||
|
- **Make the script say what it saw before diagnosing why.** Most gates here are
|
||||||
|
`curl -fsS` and bare `[[ ]]` under `set -e`, which abort silently — several CI
|
||||||
|
runs produced nothing but `make: *** Error 1`. Report the failing line and
|
||||||
|
command, print the value that failed its comparison, and dump the surrounding
|
||||||
|
state *before* any cleanup trap destroys it. Every root cause found in that
|
||||||
|
session came from doing this; essentially every confident guess made without
|
||||||
|
it was wrong.
|
||||||
|
- **Verify the diagnostics fire.** Two separate dumps were added and neither ran:
|
||||||
|
one behind a `kubectl cluster-info` guard that misjudged reachability, one
|
||||||
|
because a bare `trap ... ERR` does not fire inside functions or subshells
|
||||||
|
without `set -E`. A diagnostic that has never been seen working is not
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
And when a test and the code agree but reality disagrees, suspect they were
|
||||||
|
written together. A validator and its fixtures both encoded a response shape
|
||||||
|
Agones never sends; nothing caught it because the gate had never run far enough
|
||||||
|
to see a real one.
|
||||||
|
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
|
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
|
||||||
|
- `Game/tools/` holds editor-run utilities (`bake_arena_boundary.gd`, `gpu_profile_harness.tscn`, `replay_dump.gd` for reading server replay logs); `tools/blender/` and `tools/textures/` hold the Python generators for the original ship/ball/nebula assets.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework (see `TODO.md` for the deferred work). The three load-bearing seams are the controller abstraction, the arena/game-mode split, and code-driven spawning.
|
The structure was deliberately chosen so an RL-trained AI opponent and, later, multiplayer bolt on without rework. The load-bearing seams are the controller abstraction, the arena/game-mode split, code-driven spawning, and (for networking) the pure-function codec/state modules that can be unit-tested without a live connection.
|
||||||
|
|
||||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
### Core game
|
||||||
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
|
||||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `free_play.tscn` (practice: no timer, R resets ball), `match.tscn` (150s timer, per-team score, kickoff resets), `spectate.tscn` (bot vs bot exhibition), `settings.tscn`, or — for online — `lobby.tscn` → `networked_match.tscn`. Esc returns to the menu. Canonical paths live in `scripts/scene_paths.gd`; use those constants rather than string literals.
|
||||||
|
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; `AIShipController` runs an RL policy; `RLShipController` is driven by the training bridge; `LocalNetShipController` wraps another controller to record inputs into the network timeline. A ship with no controller is inert but simulated. The ShipAction shape *is* the RL action space and *is* what the wire format quantises — change it deliberately and everywhere at once.
|
||||||
|
- **Arena vs game mode**: an arena (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting, an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`), two `Goal` instances, `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible.
|
||||||
|
- **Arena registry**: `scripts/arena_registry.gd` is the single source of truth for the arena list — three settings × floor/elevated goal variants. `"random": true` gates which arenas Match/Spectate/the dedicated server may pick; **elevated-goal variants are Free-Play-only** until a checkpoint trained on `training_elevated.tscn` is promoted, because the current bots cannot score on an elevated goal. `path_for_match(match_index, mode)` is deliberately pure arithmetic so "the server cycles arenas" is unit-testable. **The Go control plane keeps its own copy of the ranked-eligible subset** (`server/domain/ranked.go`'s `rankedArenas`), because ranked arena selection is a server-authoritative decision made before any Godot process exists. That copy is not free to drift: `server/domain/arena_registry_sync_test.go` parses this file and fails if the two disagree in either direction, or if a ranked path has no scene behind it. Editing the arena list therefore means updating `ranked.go` too — the test says so when it fails. `arena_base.tscn` is the scenery-free physical layout the dedicated server loads (clients still render the variant `MatchSim` names).
|
||||||
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
||||||
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning and the client/server parity trace depend on it.
|
||||||
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes.
|
||||||
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
|
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless runs work.
|
||||||
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
|
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd`) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig`, connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments, not hardcoded `get_node` paths or per-frame polling. **`HUDController` duck-types on optional signals** (`timer_updated`, `match_ended`, `kickoff_countdown`, …) and hides the corresponding widget when a mode lacks one — so declaring a signal you never emit is worse than not declaring it (it shows a permanently frozen timer instead of hiding it).
|
||||||
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
|
- **`SimConstants.TICK_HZ`** is the single source of truth for the 60 Hz tick — every derived timing constant reads it rather than restating `60`. It is *not* wired to `project.godot`'s `physics/common/physics_ticks_per_second` (an engine setting), so those must be kept in sync by hand.
|
||||||
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
|
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turbo`, `reset_ball`, `toggle_perf_overlay` F3, `toggle_net_overlay` F4, …) and read only by `PlayerShipController` plus mode-level `_unhandled_input` — add new controls there rather than hardcoding key checks.
|
||||||
|
- Physics engine is Jolt (`[physics] 3d/physics_engine="Jolt Physics"`).
|
||||||
|
|
||||||
|
### `project.godot` hazard
|
||||||
|
|
||||||
|
Godot's `ConfigFile` writer does not round-trip comments, and a `#` block directly above a setting can be spliced into that setting's own line on rewrite, silently commenting it out. **Do not add comments to `Game/project.godot`.** This matters most for the feature overrides `run/main_scene.training` and `run/main_scene.dedicated_server`, which are how the training and server exports reach the right scene without a CLI flag; `tests/cases/test_project_settings.gd` fails loudly if they ever break.
|
||||||
|
|
||||||
|
### Networking (server-authoritative, with client prediction)
|
||||||
|
|
||||||
|
All hot-path RPCs live on **autoloads**, never scene nodes, so RPC NodePaths never depend on which scene is loaded. The autoload chain, in `project.godot` order:
|
||||||
|
|
||||||
|
- **`NetSim`** (`net_sim.gd`) — debug-only seeded latency/jitter/loss/duplicate decorator around outgoing dispatch. A pure passthrough unless `--net-sim-latency=` / `--net-sim-jitter=` / `--net-sim-loss=` / `--net-sim-dup=` / `--net-sim-seed=` are passed, so its mere existence changes nothing. Each process reads only its own flags, which is what makes asymmetric (e.g. lossy-upload-only) testing free.
|
||||||
|
- **`NetworkManager`** (`network_manager.gd`) — transport-neutral host/join/shutdown and connection signals. Sets `server_relay = false` the moment a peer exists (the default `true` would let any client RPC any other client through the server). Runs **manual multiplayer polling** (`set_multiplayer_poll_enabled(false)`), because SceneTree's automatic poll runs on the idle frame and would cost a frame in each direction for RPCs issued from `_physics_process` — anything driving a connection must call `NetworkManager.poll()` itself or nothing is ever sent or received.
|
||||||
|
- **`MatchNet`** (`match_net.gd`) — hello/welcome handshake, strict `protocol_version` and tick-rate gating, and the roster (name, team, ready) that survives the lobby→match transition. Slot assignment is *not* stored here; it's derived at spawn time.
|
||||||
|
- **`MatchSim`** (`match_sim.gd`) — the Phase 2+ simulation RPCs: `match_config`, `input` (client→server), `snapshot` (server→client), score/state/kickoff/goal/clock messages. Also owns protocol-level input validation (leaky-bucket packet and byte rate limits), because framing abuse is independent of any particular match's state. Emits `input_rejected` with the verbatim bytes so the replay log can explain "my input did nothing".
|
||||||
|
- **`NetDebugOverlay`** (F4) and **`PerfOverlay`** (F3) — headless-guarded read-only overlays.
|
||||||
|
|
||||||
|
Supporting modules, deliberately standalone (`RefCounted`/`class_name`, no scene or RPC dependency) so they unit-test head-on:
|
||||||
|
|
||||||
|
- `net_codec.gd` — **the wire format**. `PROTOCOL_VERSION`, channel intents, quantisers, pack/unpack for the input and snapshot packets. Any change here is a protocol change.
|
||||||
|
- `match_state.gd` — the match lifecycle enum and its legal-transition table. **The integer values are the wire format** (`match_state` is a u8 in the snapshot header): never renumber an existing state, only append.
|
||||||
|
- `net_body_state.gd`, `net_interpolator.gd`, `net_ship_predictor.gd`, `local_prediction_history.gd`, `local_input_timeline.gd`, `input_jitter_buffer.gd`, `input_lead_controller.gd`, `adaptive_input_depth_controller.gd`, `ship_action_codec.gd`, `replay_log.gd`, `server_config.gd`, `server_log.gd` — each has a header comment explaining its role and the task it came from.
|
||||||
|
|
||||||
|
`networked_match.gd` (`NetworkedMatch extends GameMode`, ~2.4k lines) is where it all meets: the server simulates every slot and broadcasts 60 Hz snapshots; a client simulates **only its own unfrozen slot** with one real controller while every remote slot and the ball stay frozen and interpolated. Its scene has no Arena or HUD child — both are built in code once the arena is actually known (the server picks it, the client learns it from `match_config`), which is why it overrides `_ready()` entirely rather than using `GameMode`'s arena-required-synchronously flow.
|
||||||
|
|
||||||
|
Server process: `scenes/server_boot.tscn` (`server_boot.gd`) is the shell — strict CLI parsing via `ServerConfig` (unknown flag or bad value refuses to start), structured logging via `ServerLog`, physics-overrun watchdog. `ServerMatchLoop` (`server_match_loop.gd`) is the actual match loop: wait for `--min-players` **by roster, not raw peers**, count down, load the next arena from the rotation, run the match, return to the lobby, repeat or drain at `--max-matches`. It parents itself to the scene-tree **root**, never `current_scene`, because `change_scene_to_file` frees the live scene and an orchestrator freed by its own transition can't orchestrate the next one.
|
||||||
|
|
||||||
|
Known-insecure, and the reason public hosting is gated: **slot reclaim is keyed by display name**, so anyone who knows a disconnected player's name can take their reserved slot. Verified Steam identity (Phase 7) is the fix. Don't expose a server to strangers before then.
|
||||||
|
|
||||||
|
### Matchmaking control plane (`server/`, Go)
|
||||||
|
|
||||||
|
The only component outside the Godot project, and roughly a third of the
|
||||||
|
codebase. Layered so policy is testable without a database and persistence
|
||||||
|
without a network:
|
||||||
|
|
||||||
|
- `domain/` (~3.2k lines) — **pure policy, no I/O**: matcher formation and
|
||||||
|
rating tolerance, Glicko ratings and tiers, proposal/queue/match state
|
||||||
|
machines, casual lineup and backfill selection, probe validation, join
|
||||||
|
authorisations. Most behaviour worth asserting lives here and needs no
|
||||||
|
fixture. `ranked.go`'s arena list is checked against `arena_registry.gd` (see
|
||||||
|
Arena registry above).
|
||||||
|
- `store/` (~5.3k) — PostgreSQL boundaries. Every mutation goes through
|
||||||
|
`RunSerializable`; contention is expected rather than exceptional, so the
|
||||||
|
retry budget and jittered backoff there are load-bearing, not decoration.
|
||||||
|
- `api/` (~2.5k) — HTTP surface and the outbox dispatchers. `Service` is a
|
||||||
|
struct of optional providers, each nil-guarded into a 503, which is why a
|
||||||
|
binary can look healthy while a whole feature is unreachable — check what
|
||||||
|
`cmd/*/main.go` actually assigns before concluding a feature is broken.
|
||||||
|
- `allocator/`, `supervisor/`, `agones/` — allocation, the Go process that
|
||||||
|
wraps the exported Godot server in an Agones pod, and the Agones client.
|
||||||
|
- `matcher/`, `workload/`, `observability/`, `steam/`, `testkit/` — the matcher
|
||||||
|
worker loop, workload-token signing, metrics, the Steam Web API adapter, and
|
||||||
|
deterministic offline fakes.
|
||||||
|
|
||||||
|
`cmd/` holds seven binaries: `control-plane`, `matcher`, `allocator`,
|
||||||
|
`maintenance`, `game-server-supervisor`, `migrate`, and `testkit-api`.
|
||||||
|
**`testkit-api` is test-only** — it injects a fake Steam login that accepts any
|
||||||
|
ticket, and must never be deployed in place of `control-plane`.
|
||||||
|
|
||||||
|
Three things that are easy to get wrong:
|
||||||
|
|
||||||
|
- **Integration tests are behind `//go:build integration`** and need a real
|
||||||
|
PostgreSQL/Redis, so `go test ./...` silently skips them. Run them through
|
||||||
|
`scripts/run_*_integration.sh`, which start their own disposable containers.
|
||||||
|
`go vet -tags integration ./...` is worth running too, or those files rot
|
||||||
|
uncompiled.
|
||||||
|
- **Config is start-time.** Tier bands, the join-signing key set, Steam
|
||||||
|
credentials and the probe providers are all read once in `main()`. Changing
|
||||||
|
them is a rolling restart, not a hot reload — deliberate, and consistent with
|
||||||
|
how everything else in these binaries is supplied.
|
||||||
|
- **The wire contract is versioned.** `contracts/v1/openapi.json` and
|
||||||
|
`state-transitions.json` are asserted by `contracts/v1/test_contracts.py`;
|
||||||
|
changing a status code or operation ID without updating them breaks generated
|
||||||
|
clients silently.
|
||||||
|
|
||||||
|
### Steam transport
|
||||||
|
|
||||||
|
`net_transport.gd` (`NetTransport`) is a deliberately narrow boundary: a transport only *creates a peer*; `NetworkManager` keeps ownership of polling, RPC policy and lifecycle. `enet_transport.gd` and `steam_transport.gd` implement it. `NetworkManager.host()/join()` default to `"enet"`; passing `"steam"` **never falls back** — a missing custom build or failed init returns an error naming the missing prerequisite (`steam_bootstrap.gd` produces those messages). Discovery and server advertisement are intentionally unimplemented until a project-owned App ID exists; the local default is Valve's Spacewar App ID 480, which must never be used to advertise servers or ship.
|
||||||
|
|
||||||
## Reinforcement learning / AI bots
|
## Reinforcement learning / AI bots
|
||||||
|
|
||||||
See `TRAINING.md` for the full workflow (training, exporting, evaluating, difficulty tiers). Architecture summary:
|
See `TRAINING.md` for the full workflow. Architecture summary:
|
||||||
|
|
||||||
- `scenes/training.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
|
- `scenes/training.tscn` / `training_elevated.tscn` (`scripts/training_mode.gd`, extends `GameMode`) is the headless self-play environment: two ships driven by `RLShipController`s, with `ShipAIController` (extends the vendored plugin's `AIController3D`) as the only class touching godot_rl types. The plugin is vendored (not a submodule) at `Game/addons/godot_rl_agents` — see its `VENDORED.md`; its C#/ONNX files are unused.
|
||||||
- `scripts/ship_observations.gd` is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
|
- `scripts/ship_observations.gd` is the shared observation builder used by training, in-game inference, **and** the server — never fork or diverge these paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
|
||||||
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/`; Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent.
|
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/` (promoted tiers in `Game/bots/promoted/`); Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent, with the main menu's `GameSettings` autoload selections overriding them.
|
||||||
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`).
|
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`), plus the curriculum drivers (`curriculum.py`, `generation5.py`) and their JSON state files.
|
||||||
- The flattened action space is Box(7) in gymnasium's **sorted-key order**: rotation xyz, thrust xyz, turbo (>0 = on). The fields are `ShipAction`'s, but gymnasium alphabetizes Dict spaces, so the flat order is NOT ShipAction's thrust-first declaration order — `AIShipController._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
|
- The flattened action space is Box(7) in gymnasium's **sorted-key order**: rotation xyz, thrust xyz, turbo (>0 = on). The fields are `ShipAction`'s, but gymnasium alphabetizes Dict spaces, so the flat order is NOT ShipAction's thrust-first declaration order — `AIShipController._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
# Local-only dedicated-server build and verification image. Pin the Godot
|
||||||
|
# release family used by project.godot; no image is pushed by this repository.
|
||||||
|
# barichello/godot-ci:4.7.1 (linux/amd64), resolved 2026-08-29.
|
||||||
|
FROM --platform=linux/amd64 barichello/godot-ci@sha256:622e5ca81b54cd8038ecf7de5d157b47efc800d7cf635af2eec18a6aee4bab7e AS project-imported
|
||||||
|
WORKDIR /workspace
|
||||||
|
# The pinned headless Godot image already runs imports without fontconfig.
|
||||||
|
# Do not refresh its old Ubuntu archive here: its historical keyring rejects
|
||||||
|
# current Noble signatures, while this source-only import stage needs no OS
|
||||||
|
# packages at all.
|
||||||
|
COPY Game /workspace/Game
|
||||||
|
# `--import` starts the editor, waits for resource import to finish, then
|
||||||
|
# exits. Do not combine it with `--quit`, which ends the editor after one
|
||||||
|
# iteration and can interrupt generation of `.godot/imported` resources.
|
||||||
|
RUN godot --headless --path Game --import \
|
||||||
|
&& test -f Game/.godot/imported/nebula_station.glb-fa9a6dd87ae3789d04205b52215e2e76.scn \
|
||||||
|
&& test -f Game/.godot/imported/nebula_debris.glb-39af77a17c0998b5b73172577d906cb9.scn \
|
||||||
|
&& test -f Game/.godot/imported/nebula_planet.glb-2431590907ff85cf1057e7d6ad614ed7.scn
|
||||||
|
|
||||||
|
# Test the source client from an untouched, fully imported project. The
|
||||||
|
# dedicated-server export below rewrites the main scene and must not be used
|
||||||
|
# to run client integration tests.
|
||||||
|
FROM project-imported AS enet-test
|
||||||
|
COPY scripts/verify_enet_integration.sh /workspace/scripts/verify_enet_integration.sh
|
||||||
|
|
||||||
|
# Godot dedicated exports disallow command-line scene overrides. Bake the
|
||||||
|
# server scene into this export (the interactive project's source stays
|
||||||
|
# unchanged), then generate the global-script/autoload metadata it needs.
|
||||||
|
FROM project-imported AS exporter
|
||||||
|
RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"|' Game/project.godot \
|
||||||
|
&& mkdir -p /opt/cosmic-clash \
|
||||||
|
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
|
||||||
|
|
||||||
|
# ubuntu:noble linux/amd64 manifest, resolved 2026-09-03. The prior pin
|
||||||
|
# carried an obsolete archive keyring and rejected current Noble signatures
|
||||||
|
# during apt-get update. This remains a digest pin; package verification is
|
||||||
|
# deliberately not bypassed.
|
||||||
|
FROM --platform=linux/amd64 ubuntu@sha256:1e0a86e57d247923571b75e0aaf48a1449cf8c543d51fb3e07a4a7d7bfa79316 AS server
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
|
||||||
|
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/cosmic-clash-server
|
||||||
|
WORKDIR /opt/cosmic-clash
|
||||||
|
EXPOSE 7777/udp
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
|
||||||
|
|
||||||
|
# Test-only target: runs the source client harness against the exported server.
|
||||||
|
FROM exporter AS smoke-client
|
||||||
|
WORKDIR /workspace
|
||||||
|
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
|
||||||
|
|
||||||
|
# Builds the process supervisor (server/supervisor, multiplayer-next.md task
|
||||||
|
# 8.27/8.28) that wraps the Agones-allocated dedicated server as PID 1.
|
||||||
|
# golang:1.23-alpine (matches server/go.mod's `go 1.23`), resolved 2026-09-01.
|
||||||
|
FROM --platform=linux/amd64 golang@sha256:383395b794dffa5b53012a212365d40c8e37109a626ca30d6151c8348d380b5f AS supervisor-build
|
||||||
|
WORKDIR /workspace/server
|
||||||
|
COPY server/go.mod server/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY server/ ./
|
||||||
|
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/game-server-supervisor ./cmd/game-server-supervisor
|
||||||
|
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/control-plane ./cmd/control-plane
|
||||||
|
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/testkit-api ./cmd/testkit-api
|
||||||
|
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/matcher ./cmd/matcher
|
||||||
|
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/allocator ./cmd/allocator
|
||||||
|
RUN CGO_ENABLED=0 go build -o /opt/cosmic-clash/maintenance ./cmd/maintenance
|
||||||
|
|
||||||
|
# Agones-allocated fleet image: the same dedicated-server export as `server`
|
||||||
|
# (unchanged above; make verify-phase6 exercises that target exactly as
|
||||||
|
# before), wrapped by the Go supervisor as PID 1 instead of the direct
|
||||||
|
# launcher script -- required for process-ready/assignment-ready Agones SDK
|
||||||
|
# calls and control-plane registration (multiplayer-next.md §8.27/§8.28).
|
||||||
|
# deploy/k8s/base/fleet.yaml invokes this target with the deployment-specific
|
||||||
|
# supervisor flags and mounts the roster/signing material required by the
|
||||||
|
# allocated startup path. Workload credentials are delivered through the
|
||||||
|
# Agones allocation annotation; a projected token volume is not required.
|
||||||
|
FROM server AS game-server
|
||||||
|
COPY --from=supervisor-build /opt/cosmic-clash/game-server-supervisor /opt/cosmic-clash/game-server-supervisor
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/game-server-supervisor
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/game-server-supervisor"]
|
||||||
|
|
||||||
|
# The production control-plane API. deploy/k8s/base/control-plane-deployment.yaml
|
||||||
|
# has always referenced this image, but nothing built it: cmd/control-plane was
|
||||||
|
# absent from the Go build stage and no target existed, so the checked-in
|
||||||
|
# Kubernetes base could not produce its own advertised topology.
|
||||||
|
#
|
||||||
|
# This must never be substituted with the testkit-api target below, which
|
||||||
|
# injects a fake login provider that accepts any ticket string.
|
||||||
|
FROM server AS control-plane
|
||||||
|
COPY --from=supervisor-build /opt/cosmic-clash/control-plane /opt/cosmic-clash/control-plane
|
||||||
|
COPY server/migrations /opt/cosmic-clash/migrations
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/control-plane
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/control-plane"]
|
||||||
|
|
||||||
|
# TEST ONLY. Supplies a fake Steam login that accepts any ticket; never deploy
|
||||||
|
# this in place of the control-plane target above.
|
||||||
|
FROM server AS testkit-api
|
||||||
|
COPY --from=supervisor-build /opt/cosmic-clash/testkit-api /opt/cosmic-clash/testkit-api
|
||||||
|
COPY server/migrations /opt/cosmic-clash/migrations
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/testkit-api
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/testkit-api"]
|
||||||
|
|
||||||
|
FROM server AS matcher
|
||||||
|
COPY --from=supervisor-build /opt/cosmic-clash/matcher /opt/cosmic-clash/matcher
|
||||||
|
COPY server/migrations /opt/cosmic-clash/migrations
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/matcher
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/matcher"]
|
||||||
|
|
||||||
|
FROM server AS allocator
|
||||||
|
COPY --from=supervisor-build /opt/cosmic-clash/allocator /opt/cosmic-clash/allocator
|
||||||
|
COPY server/migrations /opt/cosmic-clash/migrations
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/allocator
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/allocator"]
|
||||||
|
|
||||||
|
FROM server AS maintenance
|
||||||
|
COPY --from=supervisor-build /opt/cosmic-clash/maintenance /opt/cosmic-clash/maintenance
|
||||||
|
COPY server/migrations /opt/cosmic-clash/migrations
|
||||||
|
RUN chmod 0755 /opt/cosmic-clash/maintenance
|
||||||
|
ENTRYPOINT ["/opt/cosmic-clash/maintenance"]
|
||||||
+52
-12
@@ -27,18 +27,58 @@ Welcome to space, pilot! This guide will teach you everything you need to know a
|
|||||||
|
|
||||||
### Controller Controls
|
### Controller Controls
|
||||||
|
|
||||||
#### Translation
|
Button names are the Xbox layout; a PlayStation pad maps the same physical
|
||||||
|
positions (A = ✕, B = ○, X = □, Y = △).
|
||||||
|
|
||||||
- Left Stick - Strafe (Left/Right) + Thrust (Forward/Back)
|
**The left stick points the nose, the right stick moves the hull.** Your ship has
|
||||||
- Right Trigger - Forward Thrust
|
six degrees of freedom and a pad has exactly six analog axes, so every one gets a
|
||||||
- Left Trigger - Reverse Thrust
|
real axis rather than an on/off button.
|
||||||
- Face Buttons - Up/Down Thrust
|
|
||||||
|
|
||||||
#### Rotation
|
#### Rotation — left stick and shoulders
|
||||||
|
|
||||||
- Right Stick - Pitch/Yaw
|
- Left Stick (left/right) - Yaw
|
||||||
- Shoulder Buttons - Roll
|
- Left Stick (up/down) - Pitch. Flight-sim polarity by default: **push the
|
||||||
- `A Button` - Turbo Boost
|
stick forward and the nose goes down.** Flip it with "Invert pitch" in
|
||||||
|
Settings → Controls.
|
||||||
|
- `LB` - Roll Left (Bank Left)
|
||||||
|
- `RB` - Roll Right (Bank Right)
|
||||||
|
|
||||||
|
#### Translation — right stick and triggers
|
||||||
|
|
||||||
|
- `RT` - Forward Thrust (Main Engines)
|
||||||
|
- `LT` - Reverse Thrust (Retro Engines)
|
||||||
|
- Right Stick (left/right) - Strafe (Port/Starboard Thrusters)
|
||||||
|
- Right Stick (up/down) - Thrust Up/Down (Dorsal/Ventral Thrusters)
|
||||||
|
- `L3` (click the left stick) - Turbo Boost
|
||||||
|
|
||||||
|
`X` and `Y` are deliberately unused in flight, and `A`/`B` are menu-only, so a
|
||||||
|
reflexive face-button press never does anything mid-match.
|
||||||
|
|
||||||
|
#### Menus
|
||||||
|
|
||||||
|
- D-Pad or Left Stick - move the highlight
|
||||||
|
- `A` - select
|
||||||
|
- `B` - back
|
||||||
|
- `Start` - leave a match in progress (deliberately not `B`, which is too easy
|
||||||
|
to press by accident mid-game)
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
|
||||||
|
- `R3` (click the right stick) - Toggle ball camera
|
||||||
|
- `D-Pad Up` - Reset the ball (Free Play only)
|
||||||
|
|
||||||
|
The triggers and sticks are **analog**: a half-pulled trigger gives half thrust,
|
||||||
|
and a gentle stick lean gives a gentle turn. Keyboard keys are all-or-nothing,
|
||||||
|
which is the main reason a pad is easier to fly precisely.
|
||||||
|
|
||||||
|
### Rebinding
|
||||||
|
|
||||||
|
Every control above — keyboard and controller alike — can be remapped in
|
||||||
|
**Settings → Controls**. Pick the device with the Keyboard/Controller toggle,
|
||||||
|
click the binding you want to change, and press the key or button to assign.
|
||||||
|
Binding an input that is already in use unbinds it from the action that had it,
|
||||||
|
and the screen tells you which. "Reset all bindings to defaults" restores this
|
||||||
|
table.
|
||||||
|
|
||||||
## 🛸 Basic Flight Principles
|
## 🛸 Basic Flight Principles
|
||||||
|
|
||||||
@@ -69,7 +109,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
|||||||
|
|
||||||
#### Camera Control
|
#### Camera Control
|
||||||
|
|
||||||
- **Ball Cam**: Press `Enter` to toggle ball tracking camera
|
- **Ball Cam**: Press `Space` (or `R3` on a controller) to toggle ball tracking camera
|
||||||
- **Ship Cam**: Normal follow camera that looks where your ship points
|
- **Ship Cam**: Normal follow camera that looks where your ship points
|
||||||
|
|
||||||
### Intermediate Maneuvers
|
### Intermediate Maneuvers
|
||||||
@@ -115,7 +155,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
|||||||
|
|
||||||
## 🎮 Ball Cam vs Ship Cam
|
## 🎮 Ball Cam vs Ship Cam
|
||||||
|
|
||||||
### Ball Cam Mode (`Enter` to toggle)
|
### Ball Cam Mode (`Space` / `R3` to toggle)
|
||||||
|
|
||||||
- **Camera**: Always looks toward the ball
|
- **Camera**: Always looks toward the ball
|
||||||
- **Ship Control**: Based on ship orientation (NOT camera view)
|
- **Ship Control**: Based on ship orientation (NOT camera view)
|
||||||
@@ -133,7 +173,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
|
|||||||
|
|
||||||
### Turbo System
|
### Turbo System
|
||||||
|
|
||||||
- **Activation**: Hold `Shift` (keyboard) or `A` (controller) while thrusting forward
|
- **Activation**: Hold `Shift` (keyboard) or `L3` (controller) while thrusting forward
|
||||||
- **Effect**: 2.5x thrust multiplier on main engines only
|
- **Effect**: 2.5x thrust multiplier on main engines only
|
||||||
- **Strategy**: Use for quick acceleration or emergency maneuvers
|
- **Strategy**: Use for quick acceleration or emergency maneuvers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Blender authoring sources live here, but the game consumes the exported
|
||||||
|
# runtime assets under res://assets/models. Keep Godot's project scanner from
|
||||||
|
# requiring Blender when importing or testing in headless environments.
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://ckohaa5ebxym2"
|
|
||||||
path="res://.godot/imported/ball.blend-22aebbee9e0a3f479241b5a042aee325.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/blender_models/ball.blend"
|
|
||||||
dest_files=["res://.godot/imported/ball.blend-22aebbee9e0a3f479241b5a042aee325.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
mesh_library/use_node_names_as_mesh_names=false
|
|
||||||
array_mesh/deduplicate_surfaces=true
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
blender/nodes/visible=0
|
|
||||||
blender/nodes/active_collection_only=false
|
|
||||||
blender/nodes/punctual_lights=true
|
|
||||||
blender/nodes/cameras=true
|
|
||||||
blender/nodes/custom_properties=true
|
|
||||||
blender/nodes/modifiers=1
|
|
||||||
blender/meshes/vertex_colors=1
|
|
||||||
blender/meshes/uvs=true
|
|
||||||
blender/meshes/normals=true
|
|
||||||
blender/meshes/export_geometry_nodes_instances=false
|
|
||||||
blender/meshes/gpu_instances=false
|
|
||||||
blender/meshes/tangents=true
|
|
||||||
blender/meshes/skins=2
|
|
||||||
blender/meshes/export_bones_deforming_mesh_only=false
|
|
||||||
blender/materials/unpack_enabled=true
|
|
||||||
blender/materials/export_materials=1
|
|
||||||
blender/animation/limit_playback=true
|
|
||||||
blender/animation/always_sample=true
|
|
||||||
blender/animation/group_tracks=true
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/texture_map_mode=1
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://d2mrvrt1h305x"
|
|
||||||
path="res://.godot/imported/nebula_decoration.blend-2f6fe244eedad258eac5faa9b1684142.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/blender_models/nebula_decoration.blend"
|
|
||||||
dest_files=["res://.godot/imported/nebula_decoration.blend-2f6fe244eedad258eac5faa9b1684142.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
mesh_library/use_node_names_as_mesh_names=false
|
|
||||||
array_mesh/deduplicate_surfaces=true
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
blender/nodes/visible=0
|
|
||||||
blender/nodes/active_collection_only=false
|
|
||||||
blender/nodes/punctual_lights=true
|
|
||||||
blender/nodes/cameras=true
|
|
||||||
blender/nodes/custom_properties=true
|
|
||||||
blender/nodes/modifiers=1
|
|
||||||
blender/meshes/vertex_colors=1
|
|
||||||
blender/meshes/uvs=true
|
|
||||||
blender/meshes/normals=true
|
|
||||||
blender/meshes/export_geometry_nodes_instances=false
|
|
||||||
blender/meshes/gpu_instances=false
|
|
||||||
blender/meshes/tangents=true
|
|
||||||
blender/meshes/skins=2
|
|
||||||
blender/meshes/export_bones_deforming_mesh_only=false
|
|
||||||
blender/materials/unpack_enabled=true
|
|
||||||
blender/materials/export_materials=1
|
|
||||||
blender/animation/limit_playback=true
|
|
||||||
blender/animation/always_sample=true
|
|
||||||
blender/animation/group_tracks=true
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/texture_map_mode=1
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="scene"
|
|
||||||
importer_version=1
|
|
||||||
type="PackedScene"
|
|
||||||
uid="uid://bp2eqsu8o3082"
|
|
||||||
path="res://.godot/imported/ship.blend-1bdca1ba6b72cf6be2f2eb32002cf7c8.scn"
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://assets/blender_models/ship.blend"
|
|
||||||
dest_files=["res://.godot/imported/ship.blend-1bdca1ba6b72cf6be2f2eb32002cf7c8.scn"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
nodes/root_type=""
|
|
||||||
nodes/root_name=""
|
|
||||||
nodes/root_script=null
|
|
||||||
mesh_library/use_node_names_as_mesh_names=false
|
|
||||||
array_mesh/deduplicate_surfaces=true
|
|
||||||
nodes/apply_root_scale=true
|
|
||||||
nodes/root_scale=1.0
|
|
||||||
nodes/import_as_skeleton_bones=false
|
|
||||||
nodes/use_name_suffixes=true
|
|
||||||
nodes/use_node_type_suffixes=true
|
|
||||||
meshes/ensure_tangents=true
|
|
||||||
meshes/generate_lods=true
|
|
||||||
meshes/create_shadow_meshes=true
|
|
||||||
meshes/light_baking=1
|
|
||||||
meshes/lightmap_texel_size=0.2
|
|
||||||
meshes/force_disable_compression=false
|
|
||||||
skins/use_named_skins=true
|
|
||||||
animation/import=true
|
|
||||||
animation/fps=30
|
|
||||||
animation/trimming=false
|
|
||||||
animation/remove_immutable_tracks=true
|
|
||||||
animation/import_rest_as_RESET=false
|
|
||||||
import_script/path=""
|
|
||||||
materials/extract=0
|
|
||||||
materials/extract_format=0
|
|
||||||
materials/extract_path=""
|
|
||||||
_subresources={}
|
|
||||||
blender/nodes/visible=0
|
|
||||||
blender/nodes/active_collection_only=false
|
|
||||||
blender/nodes/punctual_lights=true
|
|
||||||
blender/nodes/cameras=true
|
|
||||||
blender/nodes/custom_properties=true
|
|
||||||
blender/nodes/modifiers=1
|
|
||||||
blender/meshes/vertex_colors=1
|
|
||||||
blender/meshes/uvs=true
|
|
||||||
blender/meshes/normals=true
|
|
||||||
blender/meshes/export_geometry_nodes_instances=false
|
|
||||||
blender/meshes/gpu_instances=false
|
|
||||||
blender/meshes/tangents=true
|
|
||||||
blender/meshes/skins=2
|
|
||||||
blender/meshes/export_bones_deforming_mesh_only=false
|
|
||||||
blender/materials/unpack_enabled=true
|
|
||||||
blender/materials/export_materials=1
|
|
||||||
blender/animation/limit_playback=true
|
|
||||||
blender/animation/always_sample=true
|
|
||||||
blender/animation/group_tracks=true
|
|
||||||
gltf/naming_version=2
|
|
||||||
gltf/texture_map_mode=1
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,3 +26,90 @@ texture_format/s3tc=true
|
|||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
texture_format/etc2=false
|
texture_format/etc2=false
|
||||||
binary_format/architecture="x86_64"
|
binary_format/architecture="x86_64"
|
||||||
|
|
||||||
|
[preset.1]
|
||||||
|
|
||||||
|
name="Linux Dedicated Server"
|
||||||
|
platform="Linux"
|
||||||
|
runnable=true
|
||||||
|
dedicated_server=true
|
||||||
|
custom_features="dedicated_server"
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="../server/build/CosmicClashServer.x86_64"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_encryption_key=""
|
||||||
|
|
||||||
|
[preset.1.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_script=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=false
|
||||||
|
texture_format/s3tc=false
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
|
||||||
|
[preset.2]
|
||||||
|
|
||||||
|
name="Linux Steam Client"
|
||||||
|
platform="Linux"
|
||||||
|
runnable=true
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features="steam"
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="../steam/build/CosmicClashSteam.x86_64"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_encryption_key=""
|
||||||
|
|
||||||
|
[preset.2.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_script=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
|
||||||
|
[preset.3]
|
||||||
|
|
||||||
|
name="Linux Steam Dedicated Server"
|
||||||
|
platform="Linux"
|
||||||
|
runnable=true
|
||||||
|
dedicated_server=true
|
||||||
|
custom_features="dedicated_server,steam"
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="../steam/build/CosmicClashSteamServer.x86_64"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_encryption_key=""
|
||||||
|
|
||||||
|
[preset.3.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_script=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=false
|
||||||
|
texture_format/s3tc=false
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ collision_mask = 13
|
|||||||
mass = 3
|
mass = 3
|
||||||
physics_material_override = SubResource("PhysicsMaterial_ball")
|
physics_material_override = SubResource("PhysicsMaterial_ball")
|
||||||
continuous_cd = true
|
continuous_cd = true
|
||||||
|
can_sleep = false
|
||||||
inertia = Vector3(3, 3, 3)
|
inertia = Vector3(3, 3, 3)
|
||||||
gravity_scale = 0.8
|
gravity_scale = 0.8
|
||||||
linear_damp = 0.1
|
linear_damp = 0.1
|
||||||
@@ -25,6 +26,8 @@ metadata/_edit_group_ = true
|
|||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
shape = SubResource("SphereShape3D_c5p07")
|
shape = SubResource("SphereShape3D_c5p07")
|
||||||
|
|
||||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
[node name="Visual" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Visual"]
|
||||||
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0)
|
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0)
|
||||||
mesh = ExtResource("1_ball")
|
mesh = ExtResource("1_ball")
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ collision_mask = 7
|
|||||||
mass = 5.0
|
mass = 5.0
|
||||||
physics_material_override = SubResource("PhysicsMaterial_ship")
|
physics_material_override = SubResource("PhysicsMaterial_ship")
|
||||||
inertia = Vector3(7, 1, 7)
|
inertia = Vector3(7, 1, 7)
|
||||||
|
can_sleep = false
|
||||||
|
continuous_cd = true
|
||||||
script = ExtResource("1_efag7")
|
script = ExtResource("1_efag7")
|
||||||
|
|
||||||
[node name="Nose" type="MeshInstance3D" parent="."]
|
[node name="Visual" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Nose" type="MeshInstance3D" parent="Visual"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
||||||
mesh = ExtResource("3_nose")
|
mesh = ExtResource("3_nose")
|
||||||
|
|
||||||
[node name="TailFin" type="MeshInstance3D" parent="."]
|
[node name="TailFin" type="MeshInstance3D" parent="Visual"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
|
||||||
mesh = ExtResource("5_talfin")
|
mesh = ExtResource("5_talfin")
|
||||||
|
|
||||||
|
|||||||
+63
-4
@@ -21,11 +21,22 @@ run/main_scene="uid://bcq14356s3e2i"
|
|||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
run/main_scene.training="res://scenes/training.tscn"
|
run/main_scene.training="res://scenes/training.tscn"
|
||||||
|
run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
GameSettings="*res://scripts/game_settings.gd"
|
GameSettings="*res://scripts/game_settings.gd"
|
||||||
|
ControlPlaneClient="*res://scripts/control_plane_client.gd"
|
||||||
VideoSettings="*res://scripts/video_settings.gd"
|
VideoSettings="*res://scripts/video_settings.gd"
|
||||||
|
InputSettings="*res://scripts/input_settings.gd"
|
||||||
|
BackgroundFPS="*res://scripts/background_fps.gd"
|
||||||
|
PerfOverlay="*res://scripts/perf_overlay.gd"
|
||||||
|
NetSim="*res://scripts/net_sim.gd"
|
||||||
|
NetworkManager="*res://scripts/network_manager.gd"
|
||||||
|
MatchNet="*res://scripts/match_net.gd"
|
||||||
|
MatchSim="*res://scripts/match_sim.gd"
|
||||||
|
NetDebugOverlay="*res://scripts/net_debug_overlay.gd"
|
||||||
|
AudioManager="*res://scripts/audio_manager.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
@@ -34,6 +45,7 @@ window/size/viewport_height=1080
|
|||||||
window/size/mode=2
|
window/size/mode=2
|
||||||
window/stretch/mode="viewport"
|
window/stretch/mode="viewport"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
window/vsync/vsync_mode=2
|
||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
@@ -44,42 +56,49 @@ enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
|
|||||||
reset_ball={
|
reset_ball={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
move_forward={
|
move_forward={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
move_back={
|
move_back={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
move_left={
|
move_left={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
move_right={
|
move_right={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
move_up={
|
move_up={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
move_down={
|
move_down={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
turbo={
|
turbo={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
turn_left={
|
turn_left={
|
||||||
@@ -97,13 +116,13 @@ turn_right={
|
|||||||
pitch_up={
|
pitch_up={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
|
||||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
pitch_down={
|
pitch_down={
|
||||||
"deadzone": 0.2,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
|
||||||
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
|
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
roll_left={
|
roll_left={
|
||||||
@@ -118,6 +137,42 @@ roll_right={
|
|||||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
toggle_ball_cam={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ui_cancel={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ui_accept={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194310,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
leave_gameplay={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
toggle_perf_overlay={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
toggle_net_overlay={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[layer_names]
|
[layer_names]
|
||||||
|
|
||||||
@@ -128,11 +183,15 @@ roll_right={
|
|||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
|
common/physics_jitter_fix=0.0
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
common/physics_interpolation=true
|
common/physics_interpolation=true
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
lights_and_shadows/directional_shadow/size=2048
|
||||||
anti_aliasing/quality/msaa_3d=2
|
anti_aliasing/quality/msaa_3d=2
|
||||||
anti_aliasing/quality/screen_space_aa=1
|
|
||||||
anti_aliasing/quality/use_debanding=true
|
anti_aliasing/quality/use_debanding=true
|
||||||
|
anti_aliasing/quality/screen_space_aa=1
|
||||||
|
lights_and_shadows/positional_shadow/atlas_size=2048
|
||||||
|
lights_and_shadows/soft_shadow_filter_quality=2
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
|
||||||
|
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||||
|
|
||||||
|
[node name="Lobby" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_lobby")
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
|
|
||||||
|
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 24
|
||||||
|
theme_override_constants/margin_top = 24
|
||||||
|
theme_override_constants/margin_right = 24
|
||||||
|
theme_override_constants/margin_bottom = 24
|
||||||
|
|
||||||
|
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
follow_focus = true
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(520, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 40
|
||||||
|
text = "Lobby"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
modulate = Color(1, 1, 1, 0.65)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 14
|
||||||
|
text = "Connecting..."
|
||||||
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 2
|
||||||
|
|
||||||
|
[node name="TeamsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="TeamsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 20
|
||||||
|
|
||||||
|
[node name="Team0Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Team0Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
text = "Team 1"
|
||||||
|
|
||||||
|
[node name="Team0List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 2
|
||||||
|
|
||||||
|
[node name="TeamsVSeparator" type="VSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Team1Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="Team1Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
text = "Team 2"
|
||||||
|
|
||||||
|
[node name="Team1List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 2
|
||||||
|
|
||||||
|
[node name="ControlsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="ControlsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="SwitchTeamButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Switch Team"
|
||||||
|
|
||||||
|
[node name="ReadyButton" type="CheckButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Ready"
|
||||||
|
|
||||||
|
[node name="LeaveButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Leave"
|
||||||
|
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
|
||||||
|
[connection signal="toggled" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
|
||||||
+278
-159
@@ -1,6 +1,7 @@
|
|||||||
[gd_scene load_steps=2 format=3 uid="uid://bcq14356s3e2i"]
|
[gd_scene load_steps=3 format=3 uid="uid://bcq14356s3e2i"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"]
|
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"]
|
||||||
|
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||||
|
|
||||||
[node name="MainMenu" type="Control"]
|
[node name="MainMenu" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -10,8 +11,263 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
script = ExtResource("1_menu")
|
script = ExtResource("1_menu")
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
|
|
||||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 24
|
||||||
|
theme_override_constants/margin_top = 24
|
||||||
|
theme_override_constants/margin_right = 24
|
||||||
|
theme_override_constants/margin_bottom = 24
|
||||||
|
|
||||||
|
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
follow_focus = true
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(420, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 48
|
||||||
|
text = "Cosmic Clash"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="SubtitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
modulate = Color(1, 1, 1, 0.55)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 16
|
||||||
|
text = "Physics-based soccer in space"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="TitleSpacer" type="Control" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 14)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="FreePlayButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 56)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Free Play"
|
||||||
|
|
||||||
|
[node name="FreePlayHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
modulate = Color(1, 1, 1, 0.55)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = "Solo practice — no timer, R resets the ball"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="ArenaRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="ArenaLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Arena"
|
||||||
|
|
||||||
|
[node name="ArenaDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="MatchSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="MatchHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "Match"
|
||||||
|
|
||||||
|
[node name="MatchHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
modulate = Color(1, 1, 1, 0.55)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = "A 2:30 match — you vs a trained bot"
|
||||||
|
|
||||||
|
[node name="MatchRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="DifficultyLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Difficulty"
|
||||||
|
|
||||||
|
[node name="DifficultyDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="MatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 56)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Play Match"
|
||||||
|
|
||||||
|
[node name="MultiplayerSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="MultiplayerHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "Multiplayer"
|
||||||
|
|
||||||
|
[node name="MultiplayerHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
modulate = Color(1, 1, 1, 0.55)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = "LAN / direct IP — host a match or join one"
|
||||||
|
|
||||||
|
[node name="FindMatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Find Match"
|
||||||
|
|
||||||
|
[node name="HostButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Host"
|
||||||
|
|
||||||
|
[node name="JoinRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="JoinAddressEdit" type="LineEdit" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "127.0.0.1"
|
||||||
|
placeholder_text = "IP address"
|
||||||
|
|
||||||
|
[node name="JoinButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
|
||||||
|
custom_minimum_size = Vector2(96, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Join"
|
||||||
|
|
||||||
|
[node name="MultiplayerErrorLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
modulate = Color(1, 0.5, 0.5, 1)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = ""
|
||||||
|
autowrap_mode = 2
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="SettingsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="SettingsButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 56)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Settings"
|
||||||
|
|
||||||
|
[node name="DevSection" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="DevSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="DevHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "Developer"
|
||||||
|
|
||||||
|
[node name="DevHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
modulate = Color(1, 1, 1, 0.55)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = "Dev-only — hidden in release builds"
|
||||||
|
|
||||||
|
[node name="DevOpponentRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="DevOpponentLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Opponent override"
|
||||||
|
|
||||||
|
[node name="DevBotDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="SpectateSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="SpectateHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "Spectate"
|
||||||
|
|
||||||
|
[node name="SpectateHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
modulate = Color(1, 1, 1, 0.55)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = "Watch two bots play each other"
|
||||||
|
|
||||||
|
[node name="SpectateRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="BotADropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="VsLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "vs"
|
||||||
|
|
||||||
|
[node name="BotBDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="SpectateButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
|
||||||
|
custom_minimum_size = Vector2(0, 56)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Watch Match"
|
||||||
|
|
||||||
|
[node name="ConnectingOverlay" type="Control" parent="."]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 1
|
||||||
|
|
||||||
|
[node name="Backdrop" type="ColorRect" parent="ConnectingOverlay"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
color = Color(0, 0, 0, 0.7)
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="ConnectingOverlay"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
@@ -19,168 +275,31 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
[node name="VBoxContainer" type="VBoxContainer" parent="ConnectingOverlay/CenterContainer"]
|
||||||
custom_minimum_size = Vector2(420, 0)
|
custom_minimum_size = Vector2(360, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 14
|
||||||
|
|
||||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
[node name="ConnectingStatusLabel" type="Label" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 48
|
theme_override_font_sizes/font_size = 18
|
||||||
text = "Cosmic Clash"
|
text = "Connecting..."
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 2
|
||||||
|
|
||||||
[node name="SubtitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
[node name="ConnectingCancelButton" type="Button" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
|
||||||
modulate = Color(1, 1, 1, 0.55)
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 16
|
|
||||||
text = "Physics-based soccer in space"
|
|
||||||
horizontal_alignment = 1
|
|
||||||
|
|
||||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
|
||||||
custom_minimum_size = Vector2(0, 14)
|
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
|
||||||
custom_minimum_size = Vector2(0, 56)
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Free Play"
|
|
||||||
|
|
||||||
[node name="FreePlayHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
|
||||||
modulate = Color(1, 1, 1, 0.55)
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 13
|
|
||||||
text = "Solo practice — no timer, R resets the ball"
|
|
||||||
horizontal_alignment = 1
|
|
||||||
|
|
||||||
[node name="ArenaRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_constants/separation = 10
|
|
||||||
|
|
||||||
[node name="ArenaLabel" type="Label" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Arena"
|
|
||||||
|
|
||||||
[node name="ArenaDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/ArenaRow"]
|
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(0, 40)
|
custom_minimum_size = Vector2(0, 48)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
text = "Cancel"
|
||||||
|
|
||||||
[node name="MatchSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||||
layout_mode = 2
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
|
||||||
[node name="MatchHeader" type="Label" parent="CenterContainer/VBoxContainer"]
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
|
||||||
layout_mode = 2
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
|
||||||
theme_override_font_sizes/font_size = 22
|
[connection signal="text_submitted" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
|
||||||
text = "Match"
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||||
[node name="MatchHint" type="Label" parent="CenterContainer/VBoxContainer"]
|
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
|
||||||
modulate = Color(1, 1, 1, 0.55)
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 13
|
|
||||||
text = "A 2:30 match — you vs a trained bot"
|
|
||||||
|
|
||||||
[node name="MatchRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_constants/separation = 10
|
|
||||||
|
|
||||||
[node name="DifficultyLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Difficulty"
|
|
||||||
|
|
||||||
[node name="DifficultyDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
custom_minimum_size = Vector2(0, 40)
|
|
||||||
layout_mode = 2
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
|
|
||||||
[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
|
||||||
custom_minimum_size = Vector2(0, 56)
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Play Match"
|
|
||||||
|
|
||||||
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
|
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="SettingsButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
|
||||||
custom_minimum_size = Vector2(0, 56)
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Settings"
|
|
||||||
|
|
||||||
[node name="DevSection" type="VBoxContainer" parent="CenterContainer/VBoxContainer"]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_constants/separation = 10
|
|
||||||
|
|
||||||
[node name="DevSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="DevHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 22
|
|
||||||
text = "Developer"
|
|
||||||
|
|
||||||
[node name="DevHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
modulate = Color(1, 1, 1, 0.55)
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 13
|
|
||||||
text = "Dev-only — hidden in release builds"
|
|
||||||
|
|
||||||
[node name="DevOpponentRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_constants/separation = 10
|
|
||||||
|
|
||||||
[node name="DevOpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Opponent override"
|
|
||||||
|
|
||||||
[node name="DevBotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
custom_minimum_size = Vector2(0, 40)
|
|
||||||
layout_mode = 2
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
|
|
||||||
[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
layout_mode = 2
|
|
||||||
|
|
||||||
[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 22
|
|
||||||
text = "Spectate"
|
|
||||||
|
|
||||||
[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
modulate = Color(1, 1, 1, 0.55)
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 13
|
|
||||||
text = "Watch two bots play each other"
|
|
||||||
|
|
||||||
[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_constants/separation = 10
|
|
||||||
|
|
||||||
[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
custom_minimum_size = Vector2(0, 40)
|
|
||||||
layout_mode = 2
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
|
|
||||||
[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
|
||||||
layout_mode = 2
|
|
||||||
text = "vs"
|
|
||||||
|
|
||||||
[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
|
|
||||||
unique_name_in_owner = true
|
|
||||||
custom_minimum_size = Vector2(0, 40)
|
|
||||||
layout_mode = 2
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
|
|
||||||
[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer/DevSection"]
|
|
||||||
custom_minimum_size = Vector2(0, 56)
|
|
||||||
layout_mode = 2
|
|
||||||
text = "Watch Match"
|
|
||||||
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
|
|
||||||
|
|||||||
@@ -7,5 +7,4 @@
|
|||||||
script = ExtResource("1_m")
|
script = ExtResource("1_m")
|
||||||
bot_model_path = "res://bots/promoted/easy.json"
|
bot_model_path = "res://bots/promoted/easy.json"
|
||||||
|
|
||||||
|
|
||||||
[node name="HUD" parent="." instance=ExtResource("3_m")]
|
[node name="HUD" parent="." instance=ExtResource("3_m")]
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/matchmaking.gd" id="1_matchmaking"]
|
||||||
|
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||||
|
|
||||||
|
[node name="Matchmaking" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_matchmaking")
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
|
|
||||||
|
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 24
|
||||||
|
theme_override_constants/margin_top = 24
|
||||||
|
theme_override_constants/margin_right = 24
|
||||||
|
theme_override_constants/margin_bottom = 24
|
||||||
|
|
||||||
|
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
follow_focus = true
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(480, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 40
|
||||||
|
text = "Find a Match"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="PlaylistDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "Ready to search"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="DetailLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
modulate = Color(1, 1, 1, 0.65)
|
||||||
|
layout_mode = 2
|
||||||
|
autowrap_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="RankedProfileLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
modulate = Color(1, 1, 1, 0.65)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Ranked profile unavailable"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="QueueButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 52)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Search"
|
||||||
|
|
||||||
|
[node name="CancelButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Cancel Search"
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="ProposalRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="AcceptButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Accept"
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="DeclineButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Decline"
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
[node name="BackButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Back"
|
||||||
|
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/networked_match.gd" id="1_nm"]
|
||||||
|
|
||||||
|
[node name="NetworkedMatch" type="Node3D"]
|
||||||
|
script = ExtResource("1_nm")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/server_boot.gd" id="1_sb"]
|
||||||
|
|
||||||
|
[node name="ServerBoot" type="Node"]
|
||||||
|
script = ExtResource("1_sb")
|
||||||
+203
-26
@@ -1,6 +1,8 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene load_steps=4 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/settings_menu.gd" id="1_settings"]
|
[ext_resource type="Script" path="res://scripts/settings_menu.gd" id="1_settings"]
|
||||||
|
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
|
||||||
|
[ext_resource type="Script" path="res://scripts/controls_settings.gd" id="3_controls"]
|
||||||
|
|
||||||
[node name="SettingsMenu" type="Control"]
|
[node name="SettingsMenu" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -10,55 +12,117 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
script = ExtResource("1_settings")
|
script = ExtResource("1_settings")
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
|
|
||||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 24
|
||||||
|
theme_override_constants/margin_top = 24
|
||||||
|
theme_override_constants/margin_right = 24
|
||||||
|
theme_override_constants/margin_bottom = 24
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||||
custom_minimum_size = Vector2(420, 0)
|
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 36
|
theme_override_font_sizes/font_size = 36
|
||||||
text = "Settings"
|
text = "Settings"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
|
||||||
custom_minimum_size = Vector2(0, 14)
|
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
tab_alignment = 1
|
||||||
|
|
||||||
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="Video" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
follow_focus = true
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(420, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
[node name="AALabel" type="Label" parent="CenterContainer/VBoxContainer/AARow"]
|
[node name="PresetRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="PresetLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
|
||||||
custom_minimum_size = Vector2(110, 0)
|
custom_minimum_size = Vector2(110, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Anti-aliasing"
|
text = "Graphics preset"
|
||||||
|
|
||||||
[node name="AADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/AARow"]
|
[node name="PresetDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(0, 40)
|
custom_minimum_size = Vector2(0, 40)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="AARow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
[node name="GlowLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
|
[node name="AALabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Anti-aliasing"
|
||||||
|
|
||||||
|
[node name="AADropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="ResolutionRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="ResolutionLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Resolution scale"
|
||||||
|
|
||||||
|
[node name="ResolutionSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 24)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 4
|
||||||
|
min_value = 0.5
|
||||||
|
max_value = 1.0
|
||||||
|
step = 0.05
|
||||||
|
value = 1.0
|
||||||
|
|
||||||
|
[node name="ResolutionValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(48, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "100%"
|
||||||
|
horizontal_alignment = 2
|
||||||
|
|
||||||
|
[node name="GlowRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="GlowLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||||
custom_minimum_size = Vector2(110, 0)
|
custom_minimum_size = Vector2(110, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Glow intensity"
|
text = "Glow intensity"
|
||||||
|
|
||||||
[node name="GlowSlider" type="HSlider" parent="CenterContainer/VBoxContainer/GlowRow"]
|
[node name="GlowSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(0, 24)
|
custom_minimum_size = Vector2(0, 24)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -69,23 +133,23 @@ max_value = 1.5
|
|||||||
step = 0.05
|
step = 0.05
|
||||||
value = 1.0
|
value = 1.0
|
||||||
|
|
||||||
[node name="GlowValueLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
|
[node name="GlowValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(48, 0)
|
custom_minimum_size = Vector2(48, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "100%"
|
text = "100%"
|
||||||
horizontal_alignment = 2
|
horizontal_alignment = 2
|
||||||
|
|
||||||
[node name="BrightnessRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="BrightnessRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
[node name="BrightnessLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
[node name="BrightnessLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||||
custom_minimum_size = Vector2(110, 0)
|
custom_minimum_size = Vector2(110, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Brightness"
|
text = "Brightness"
|
||||||
|
|
||||||
[node name="BrightnessSlider" type="HSlider" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
[node name="BrightnessSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(0, 24)
|
custom_minimum_size = Vector2(0, 24)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -96,23 +160,136 @@ max_value = 1.3
|
|||||||
step = 0.02
|
step = 0.02
|
||||||
value = 1.0
|
value = 1.0
|
||||||
|
|
||||||
[node name="BrightnessValueLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
|
[node name="BrightnessValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
custom_minimum_size = Vector2(48, 0)
|
custom_minimum_size = Vector2(48, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "100%"
|
text = "100%"
|
||||||
horizontal_alignment = 2
|
horizontal_alignment = 2
|
||||||
|
|
||||||
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
[node name="VsyncRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
custom_minimum_size = Vector2(0, 14)
|
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
[node name="VsyncLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "VSync"
|
||||||
|
|
||||||
|
[node name="VsyncDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="FpsCapRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="FpsCapLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "FPS cap"
|
||||||
|
|
||||||
|
[node name="FpsCapDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="FpsReadoutRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="FpsReadoutTitleLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Current"
|
||||||
|
|
||||||
|
[node name="FpsReadoutLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "0 fps"
|
||||||
|
|
||||||
|
[node name="Controls" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
follow_focus = true
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
script = ExtResource("3_controls")
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(520, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="DeviceRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="DeviceLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||||
|
custom_minimum_size = Vector2(160, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Device"
|
||||||
|
|
||||||
|
[node name="KeyboardButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
toggle_mode = true
|
||||||
|
button_pressed = true
|
||||||
|
text = "Keyboard"
|
||||||
|
|
||||||
|
[node name="ControllerButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
toggle_mode = true
|
||||||
|
text = "Controller"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
modulate = Color(1, 0.85, 0.5, 1)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
text = ""
|
||||||
|
autowrap_mode = 2
|
||||||
|
|
||||||
|
[node name="BindingList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="InvertPitchCheck" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Invert pitch"
|
||||||
|
|
||||||
|
[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 44)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Reset all bindings to defaults"
|
||||||
|
|
||||||
|
[node name="BackButton" type="Button" parent="MarginContainer/VBoxContainer"]
|
||||||
custom_minimum_size = Vector2(0, 56)
|
custom_minimum_size = Vector2(0, 56)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Back"
|
text = "Back"
|
||||||
|
|
||||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||||
|
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||||
|
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||||
|
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||||
|
[connection signal="pressed" from="MarginContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class_name HUDController
|
|||||||
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
|
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
|
||||||
|
|
||||||
var ship: Node
|
var ship: Node
|
||||||
|
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
|
||||||
|
# the local peer has no ship of its own. Distinct from `ship == null` by
|
||||||
|
# accident: a missing ship is still an error for a player, and silently
|
||||||
|
# degrading to a spectator HUD would hide that.
|
||||||
|
var spectator_mode := false
|
||||||
var _last_score := {0: 0, 1: 0}
|
var _last_score := {0: 0, 1: 0}
|
||||||
var _goal_tween: Tween
|
var _goal_tween: Tween
|
||||||
|
|
||||||
@@ -43,6 +48,16 @@ func _initialize_hud():
|
|||||||
# this runs — not discovered via group, since the "ship" group can have
|
# this runs — not discovered via group, since the "ship" group can have
|
||||||
# 2+ members and there's no reliable way to tell which one is "ours".
|
# 2+ members and there's no reliable way to tell which one is "ours".
|
||||||
if not ship:
|
if not ship:
|
||||||
|
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
|
||||||
|
# must still get the score, clock and goal celebration. Only the
|
||||||
|
# per-ship instrument cluster is meaningless without one, so hide that
|
||||||
|
# and carry on wiring everything else — this used to push_error and
|
||||||
|
# bail, which left a spectator with a completely dead HUD.
|
||||||
|
if spectator_mode:
|
||||||
|
print("HUDController: spectator mode — hiding ship instruments")
|
||||||
|
_hide_ship_instruments()
|
||||||
|
_connect_mode_signals()
|
||||||
|
return
|
||||||
push_error("HUDController: No ship assigned")
|
push_error("HUDController: No ship assigned")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -59,6 +74,24 @@ func _initialize_hud():
|
|||||||
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
|
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
|
||||||
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
|
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
|
||||||
|
|
||||||
|
_connect_mode_signals()
|
||||||
|
|
||||||
|
|
||||||
|
func _hide_ship_instruments() -> void:
|
||||||
|
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
|
||||||
|
# meaning without a ship. Everything else on the HUD still does.
|
||||||
|
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
|
||||||
|
if node and is_instance_valid(node):
|
||||||
|
node.visible = false
|
||||||
|
var cluster := get_node_or_null("Control/Instruments/Cluster")
|
||||||
|
if cluster and is_instance_valid(cluster):
|
||||||
|
cluster.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
# Everything that depends on the MODE rather than on owning a ship: clock,
|
||||||
|
# score, team identity, match-ended, kickoff countdown. A spectator gets all
|
||||||
|
# of it.
|
||||||
|
func _connect_mode_signals() -> void:
|
||||||
# Connect to game manager's timer signal; modes without a timer
|
# Connect to game manager's timer signal; modes without a timer
|
||||||
# (e.g. free play) just don't show one
|
# (e.g. free play) just don't show one
|
||||||
var game_manager = get_tree().get_first_node_in_group("game")
|
var game_manager = get_tree().get_first_node_in_group("game")
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
class_name AdaptiveInputDepthController
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Client-only policy for choosing whether the server's input jitter buffer may
|
||||||
|
# run at depth zero. It deliberately does not change server buffering, action
|
||||||
|
# encoding, or bot behavior; NetworkedMatch pins --test-bot clients at depth 1.
|
||||||
|
|
||||||
|
const TARGET_DEPTH_SAFE := 1
|
||||||
|
const TARGET_DEPTH_LOW_LATENCY := 0
|
||||||
|
const CLEAN_JITTER_MS := 3.0
|
||||||
|
const EXIT_JITTER_MS := 5.0
|
||||||
|
const REQUIRED_STABLE_TICKS := 240
|
||||||
|
const REENTRY_COOLDOWN_TICKS := 120
|
||||||
|
|
||||||
|
var target_depth := TARGET_DEPTH_SAFE
|
||||||
|
var stable_low_jitter_ticks := 0
|
||||||
|
var cooldown_ticks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func update(rtt_ms: float, jitter_ms: float, advertised_depth: int) -> int:
|
||||||
|
if cooldown_ticks > 0:
|
||||||
|
cooldown_ticks -= 1
|
||||||
|
# -2 is a genuine server starvation sentinel. -1 means no header yet and
|
||||||
|
# must not be mistaken for starvation.
|
||||||
|
if advertised_depth < -1 or jitter_ms > EXIT_JITTER_MS:
|
||||||
|
target_depth = TARGET_DEPTH_SAFE
|
||||||
|
stable_low_jitter_ticks = 0
|
||||||
|
cooldown_ticks = REENTRY_COOLDOWN_TICKS
|
||||||
|
return target_depth
|
||||||
|
if rtt_ms >= 0.0 and jitter_ms < CLEAN_JITTER_MS:
|
||||||
|
stable_low_jitter_ticks += 1
|
||||||
|
if stable_low_jitter_ticks >= REQUIRED_STABLE_TICKS and cooldown_ticks == 0:
|
||||||
|
target_depth = TARGET_DEPTH_LOW_LATENCY
|
||||||
|
else:
|
||||||
|
stable_low_jitter_ticks = 0
|
||||||
|
target_depth = TARGET_DEPTH_SAFE
|
||||||
|
return target_depth
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dofgtukllr7yr
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
class_name AgonesSDK
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Dependency-free REST bridge for the Agones sidecar. The Go supervisor owns
|
||||||
|
# the process-ready probe and /ready transition; this node owns the game
|
||||||
|
# process's periodic Health pings and terminal Shutdown/annotation calls.
|
||||||
|
|
||||||
|
const HEALTH_INTERVAL_SECONDS := 2.0
|
||||||
|
const REQUEST_TIMEOUT_SECONDS := 2.0
|
||||||
|
const MAX_ANNOTATION_VALUE_LENGTH := 4096
|
||||||
|
|
||||||
|
var _base_url := ""
|
||||||
|
var _health_timer: Timer = null
|
||||||
|
var _health_in_flight := false
|
||||||
|
var _health_started_msec := 0
|
||||||
|
# Set when start_health() is called before this node is inside the tree, so
|
||||||
|
# _ready() can arm the timer at the first moment it is legal to do so.
|
||||||
|
var _health_pending := false
|
||||||
|
|
||||||
|
|
||||||
|
# Health is armed here rather than by the caller. A Timer only ticks while its
|
||||||
|
# owner is inside the SceneTree, so arming it from a caller that has not yet
|
||||||
|
# parented this node produces a node that looks configured and never pings —
|
||||||
|
# which is exactly how every allocated GameServer silently failed its Agones
|
||||||
|
# health check and was recycled.
|
||||||
|
func _ready() -> void:
|
||||||
|
if _health_pending:
|
||||||
|
_health_pending = false
|
||||||
|
_arm_health()
|
||||||
|
|
||||||
|
|
||||||
|
func configure_from_environment() -> bool:
|
||||||
|
var port := OS.get_environment("AGONES_SDK_HTTP_PORT")
|
||||||
|
if port.is_empty() or not port.is_valid_int() or int(port) < 1 or int(port) > 65535:
|
||||||
|
return false
|
||||||
|
_base_url = "http://127.0.0.1:%d" % int(port)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func configure_for_testing(base_url: String) -> bool:
|
||||||
|
if not base_url.begins_with("http://127.0.0.1:") and not base_url.begins_with("http://localhost:"):
|
||||||
|
return false
|
||||||
|
_base_url = base_url.trim_suffix("/")
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func is_available() -> bool:
|
||||||
|
return not _base_url.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
# Returns whether health pings are running. It is a bool rather than void
|
||||||
|
# because every way this can fail used to be silent, and a game server that
|
||||||
|
# believes it is healthy while sending nothing is worse than one that refuses
|
||||||
|
# to start: Agones recycles the former every ~20 seconds forever.
|
||||||
|
func start_health() -> bool:
|
||||||
|
if not is_available():
|
||||||
|
push_error("AgonesSDK: start_health() before configuration; no health pings will be sent")
|
||||||
|
return false
|
||||||
|
if _health_timer != null:
|
||||||
|
return true
|
||||||
|
if not is_inside_tree():
|
||||||
|
# Deferred rather than fatal: the caller may legitimately configure
|
||||||
|
# before parenting. _ready() arms it. Still reported, because if the
|
||||||
|
# node is never parented this is the whole failure.
|
||||||
|
_health_pending = true
|
||||||
|
push_warning("AgonesSDK: start_health() called outside the tree; deferring until ready")
|
||||||
|
return false
|
||||||
|
_arm_health()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _arm_health() -> void:
|
||||||
|
if _health_timer != null:
|
||||||
|
return
|
||||||
|
_health_timer = Timer.new()
|
||||||
|
_health_timer.name = "AgonesHealth"
|
||||||
|
_health_timer.wait_time = HEALTH_INTERVAL_SECONDS
|
||||||
|
_health_timer.one_shot = false
|
||||||
|
_health_timer.timeout.connect(_send_health)
|
||||||
|
add_child(_health_timer)
|
||||||
|
_health_timer.start()
|
||||||
|
_send_health()
|
||||||
|
|
||||||
|
|
||||||
|
func health_is_running() -> bool:
|
||||||
|
return _health_timer != null and is_inside_tree()
|
||||||
|
|
||||||
|
|
||||||
|
func stop_health() -> void:
|
||||||
|
if _health_timer != null:
|
||||||
|
_health_timer.stop()
|
||||||
|
_health_timer.queue_free()
|
||||||
|
_health_timer = null
|
||||||
|
|
||||||
|
|
||||||
|
func health() -> int:
|
||||||
|
return await _request(HTTPClient.METHOD_POST, "/health", {})
|
||||||
|
|
||||||
|
|
||||||
|
func mark_ready() -> int:
|
||||||
|
return await _request(HTTPClient.METHOD_POST, "/ready", {})
|
||||||
|
|
||||||
|
|
||||||
|
func shutdown() -> int:
|
||||||
|
return await _request(HTTPClient.METHOD_POST, "/shutdown", {})
|
||||||
|
|
||||||
|
|
||||||
|
func set_annotation(key: String, value: String) -> int:
|
||||||
|
if not annotation_is_valid(key, value):
|
||||||
|
return 400
|
||||||
|
return await _request(HTTPClient.METHOD_PUT, "/metadata/annotation", {"key": key, "value": value})
|
||||||
|
|
||||||
|
|
||||||
|
static func annotation_is_valid(key: String, value: String) -> bool:
|
||||||
|
return not (key.is_empty() or value.is_empty() or value.length() > MAX_ANNOTATION_VALUE_LENGTH or "\n" in key or "\r" in key or "\n" in value or "\r" in value)
|
||||||
|
|
||||||
|
|
||||||
|
func _send_health() -> void:
|
||||||
|
if not is_available():
|
||||||
|
return
|
||||||
|
# The latch stops overlapping requests, but it must never become permanent.
|
||||||
|
# It is set across an await, and a request that never completes would
|
||||||
|
# otherwise silence health for the lifetime of the process. HTTPRequest's
|
||||||
|
# own timeout normally resolves this; the elapsed check is the backstop for
|
||||||
|
# the case where request_completed never fires at all.
|
||||||
|
if _health_in_flight:
|
||||||
|
var stuck_for := Time.get_ticks_msec() - _health_started_msec
|
||||||
|
if stuck_for < int(REQUEST_TIMEOUT_SECONDS * 2.0 * 1000.0):
|
||||||
|
return
|
||||||
|
push_warning("Agones health ping did not complete in %dms; sending another" % stuck_for)
|
||||||
|
_health_in_flight = true
|
||||||
|
_health_started_msec = Time.get_ticks_msec()
|
||||||
|
var status := await health()
|
||||||
|
_health_in_flight = false
|
||||||
|
if status < 200 or status >= 300:
|
||||||
|
push_warning("Agones health ping failed (%d)" % status)
|
||||||
|
|
||||||
|
|
||||||
|
func _request(method: int, path: String, payload: Dictionary) -> int:
|
||||||
|
if not is_available() or not path.begins_with("/"):
|
||||||
|
return 408
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.timeout = REQUEST_TIMEOUT_SECONDS
|
||||||
|
add_child(request)
|
||||||
|
var body := JSON.stringify(payload)
|
||||||
|
var err := request.request(_base_url + path, PackedStringArray(["Content-Type: application/json"]), method, body)
|
||||||
|
if err != OK:
|
||||||
|
request.queue_free()
|
||||||
|
return 599
|
||||||
|
var result = await request.request_completed
|
||||||
|
request.queue_free()
|
||||||
|
return int(result[1])
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
class_name AIShipController
|
class_name AIShipController
|
||||||
extends ShipController
|
extends ShipController
|
||||||
|
|
||||||
|
# Emitted if a cached teammate/opponent reference is found freed and dropped
|
||||||
|
# from the roster (see _decide). No despawn path exists anywhere in this
|
||||||
|
# codebase today — rosters are fixed at match start — so this never fires in
|
||||||
|
# practice; it's cheap insurance against ShipObservations.build() crashing on
|
||||||
|
# a stale reference if that ever changes.
|
||||||
|
signal roster_changed
|
||||||
|
|
||||||
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
|
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
|
||||||
# same canonical observation as training (ShipObservations) and runs the
|
# same canonical observation as training (ShipObservations) and runs the
|
||||||
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
|
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
|
||||||
@@ -50,6 +57,13 @@ var _scene_refs_ready := false
|
|||||||
func _ready():
|
func _ready():
|
||||||
if not model_path.is_empty():
|
if not model_path.is_empty():
|
||||||
load_policy(model_path)
|
load_policy(model_path)
|
||||||
|
# Stagger the first decision across [1, reaction_ticks] so bots sharing a
|
||||||
|
# reaction cadence don't all run policy inference on the same physics
|
||||||
|
# tick — six bots landing together is a ~2.4 ms spike in a 16.7 ms budget
|
||||||
|
# (policy_network.gd's forward pass). The phase offset this establishes
|
||||||
|
# persists across subsequent decisions since each one re-arms the same
|
||||||
|
# period from wherever _ticks_until_decision currently sits.
|
||||||
|
_ticks_until_decision = randi_range(1, maxi(reaction_ticks, 1))
|
||||||
|
|
||||||
|
|
||||||
# League training swaps a frozen opponent's policy between episodes without
|
# League training swaps a frozen opponent's policy between episodes without
|
||||||
@@ -83,6 +97,11 @@ func get_action() -> ShipAction:
|
|||||||
|
|
||||||
|
|
||||||
func _decide() -> void:
|
func _decide() -> void:
|
||||||
|
if _teammates.any(func(s): return not is_instance_valid(s)) \
|
||||||
|
or _opponents.any(func(s): return not is_instance_valid(s)):
|
||||||
|
_teammates = _teammates.filter(is_instance_valid)
|
||||||
|
_opponents = _opponents.filter(is_instance_valid)
|
||||||
|
roster_changed.emit()
|
||||||
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
|
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
|
||||||
var out := _policy.forward(obs)
|
var out := _policy.forward(obs)
|
||||||
# See ShipActionCodec for the decode — the single source of truth shared
|
# See ShipActionCodec for the decode — the single source of truth shared
|
||||||
|
|||||||
+48
-12
@@ -19,23 +19,59 @@ extends Node3D
|
|||||||
@export var glow_hdr_threshold := 1.0
|
@export var glow_hdr_threshold := 1.0
|
||||||
|
|
||||||
|
|
||||||
|
var _env: Environment
|
||||||
|
# Lights authored with shadow_enabled = true (the DirectionalLight3D + 4
|
||||||
|
# PitchLights omnis) — captured once, before gating ever touches them. Every
|
||||||
|
# call after the first re-applies VideoSettings.shadows_enabled to exactly
|
||||||
|
# these lights, so the set can't self-poison (if it were re-derived from
|
||||||
|
# current state, a light this same code just turned off would look
|
||||||
|
# indistinguishable from FillLight, which is authored off on purpose and must
|
||||||
|
# never be turned on by the preset ladder).
|
||||||
|
var _shadow_capable_lights: Array[Light3D] = []
|
||||||
|
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
add_to_group("arena")
|
add_to_group("arena")
|
||||||
|
# A headless server never renders, so duplicating and configuring a full
|
||||||
|
# Environment (glow/SSAO/SSIL/SDFGI) for it is pure waste — mirrors the
|
||||||
|
# same guard at ship.gd and arena_boundary.gd.
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||||
if world_env and world_env.environment:
|
if world_env and world_env.environment:
|
||||||
var env := world_env.environment.duplicate(true) as Environment
|
_env = world_env.environment.duplicate(true) as Environment
|
||||||
world_env.environment = env
|
world_env.environment = _env
|
||||||
if sky_material:
|
if sky_material:
|
||||||
if not env.sky:
|
if not _env.sky:
|
||||||
env.sky = Sky.new()
|
_env.sky = Sky.new()
|
||||||
env.sky.sky_material = sky_material
|
_env.sky.sky_material = sky_material
|
||||||
env.ambient_light_color = ambient_light_color
|
_env.ambient_light_color = ambient_light_color
|
||||||
env.ambient_light_energy = ambient_light_energy
|
_env.ambient_light_energy = ambient_light_energy
|
||||||
env.glow_intensity = glow_intensity
|
_env.glow_intensity = glow_intensity
|
||||||
env.glow_strength = glow_strength
|
_env.glow_strength = glow_strength
|
||||||
env.glow_bloom = glow_bloom
|
_env.glow_bloom = glow_bloom
|
||||||
env.glow_hdr_threshold = glow_hdr_threshold
|
_env.glow_hdr_threshold = glow_hdr_threshold
|
||||||
VideoSettings.apply_to_environment(env)
|
for light in find_children("*", "Light3D", true, false):
|
||||||
|
if (light as Light3D).shadow_enabled:
|
||||||
|
_shadow_capable_lights.append(light)
|
||||||
|
_apply_video_settings()
|
||||||
|
# Task 0.17: a preset change from the settings menu must take effect
|
||||||
|
# on the arena that's already loaded, not just the next one — this is
|
||||||
|
# the "settings persist and apply without a restart" acceptance bar.
|
||||||
|
VideoSettings.settings_changed.connect(_apply_video_settings)
|
||||||
|
|
||||||
|
|
||||||
|
# Re-run on every VideoSettings.settings_changed (preset or individual
|
||||||
|
# toggle) as well as once at load. Shadow gating lives here rather than in
|
||||||
|
# VideoSettings.apply_to_environment() because it targets Light3D nodes in
|
||||||
|
# this arena's own tree, not the Environment resource.
|
||||||
|
func _apply_video_settings() -> void:
|
||||||
|
if not is_instance_valid(_env):
|
||||||
|
return
|
||||||
|
VideoSettings.apply_to_environment(_env)
|
||||||
|
for light in _shadow_capable_lights:
|
||||||
|
if is_instance_valid(light):
|
||||||
|
light.shadow_enabled = VideoSettings.shadows_enabled
|
||||||
|
|
||||||
|
|
||||||
func get_ball_spawn() -> Transform3D:
|
func get_ball_spawn() -> Transform3D:
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ var _field_material: ShaderMaterial
|
|||||||
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
|
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
|
||||||
# pattern so the viewport lookup isn't repeated every frame.
|
# pattern so the viewport lookup isn't repeated every frame.
|
||||||
var _camera: Camera3D
|
var _camera: Camera3D
|
||||||
|
var _last_camera_local_pos := Vector3.INF
|
||||||
|
# Below this, the shader's per-pixel facing test can't produce a visibly
|
||||||
|
# different result — skip the to_local()/set_shader_parameter() call.
|
||||||
|
const CAMERA_UNIFORM_UPDATE_THRESHOLD := 0.05
|
||||||
|
|
||||||
# Group every generated collider is tagged with. A CollisionShape3D only
|
# Group every generated collider is tagged with. A CollisionShape3D only
|
||||||
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
|
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
|
||||||
@@ -184,6 +188,14 @@ func get_surface_pull(
|
|||||||
global_pos: Vector3, wall_strength: float, wall_range: float,
|
global_pos: Vector3, wall_strength: float, wall_range: float,
|
||||||
ceiling_strength: float, ceiling_range: float
|
ceiling_strength: float, ceiling_range: float
|
||||||
) -> Vector3:
|
) -> Vector3:
|
||||||
|
# Early-out: every dynamic body pays to_local() plus five _falloff calls
|
||||||
|
# every tick even mid-arena, where every term is exactly zero. Compared
|
||||||
|
# directly against global_pos, matching the same identity-transform
|
||||||
|
# assumption GameMode._is_escaped already makes against these constants.
|
||||||
|
if absf(global_pos.x) < INNER_HALF_X - wall_range \
|
||||||
|
and absf(global_pos.z) < INNER_HALF_Z - wall_range \
|
||||||
|
and global_pos.y < INNER_HEIGHT - ceiling_range:
|
||||||
|
return Vector3.ZERO
|
||||||
var p := to_local(global_pos)
|
var p := to_local(global_pos)
|
||||||
var pull := Vector3.ZERO
|
var pull := Vector3.ZERO
|
||||||
pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength
|
pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength
|
||||||
@@ -214,7 +226,11 @@ func _process(_delta: float) -> void:
|
|||||||
var camera := _get_camera()
|
var camera := _get_camera()
|
||||||
if camera == null:
|
if camera == null:
|
||||||
return # headless (RL/CI) has no camera
|
return # headless (RL/CI) has no camera
|
||||||
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
|
var local_pos := to_local(camera.global_position)
|
||||||
|
if local_pos.distance_to(_last_camera_local_pos) < CAMERA_UNIFORM_UPDATE_THRESHOLD:
|
||||||
|
return
|
||||||
|
_last_camera_local_pos = local_pos
|
||||||
|
_field_material.set_shader_parameter("camera_local_pos", local_pos)
|
||||||
|
|
||||||
|
|
||||||
# Caches the viewport's active camera; a plain is_instance_valid revalidation
|
# Caches the viewport's active camera; a plain is_instance_valid revalidation
|
||||||
|
|||||||
@@ -24,3 +24,26 @@ const ARENAS := [
|
|||||||
static func random_path() -> String:
|
static func random_path() -> String:
|
||||||
var candidates := ARENAS.filter(func(arena): return arena["random"])
|
var candidates := ARENAS.filter(func(arena): return arena["random"])
|
||||||
return candidates[randi() % candidates.size()]["path"]
|
return candidates[randi() % candidates.size()]["path"]
|
||||||
|
|
||||||
|
|
||||||
|
# The arenas a server may rotate through, in declaration order. Same filter as
|
||||||
|
# random_path(): an elevated-goal variant is Free-Play-only until a checkpoint
|
||||||
|
# trained on it is promoted, and a dedicated server rotating onto one would
|
||||||
|
# hand every bot-filled slot an arena it cannot score in.
|
||||||
|
static func rotation_paths() -> Array:
|
||||||
|
return ARENAS.filter(func(arena): return arena["random"]).map(func(arena): return arena["path"])
|
||||||
|
|
||||||
|
|
||||||
|
# Task 6.5's arena rotation, as pure arithmetic so it is unit-testable without
|
||||||
|
# a server: given how many matches have already been played, which arena is
|
||||||
|
# next. `random` deliberately still uses the global RNG (the caller wants
|
||||||
|
# variety, not reproducibility); `sequential` is a pure function of the count,
|
||||||
|
# which is what makes "the server cycles arenas" an assertable claim rather
|
||||||
|
# than an observation about luck.
|
||||||
|
static func path_for_match(match_index: int, mode: String) -> String:
|
||||||
|
var paths := rotation_paths()
|
||||||
|
if paths.is_empty():
|
||||||
|
return ARENAS[0]["path"]
|
||||||
|
if mode == "random":
|
||||||
|
return paths[randi() % paths.size()]
|
||||||
|
return paths[posmod(match_index, paths.size())]
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
class_name AssignmentState
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Verified assignment-ready manifest returned by the control plane. The join
|
||||||
|
# authorisation is retained in memory only and is never written to the restart
|
||||||
|
# snapshot; transport installation belongs to the explicit ENet/Steam layer.
|
||||||
|
|
||||||
|
var available := false
|
||||||
|
var match_id := ""
|
||||||
|
var server_id := ""
|
||||||
|
var slot := -1
|
||||||
|
var expires_at := ""
|
||||||
|
var protocol_version := 0
|
||||||
|
var transport := ""
|
||||||
|
var endpoint := ""
|
||||||
|
var join_authorisation := ""
|
||||||
|
var error_message := ""
|
||||||
|
|
||||||
|
|
||||||
|
func apply(payload: Dictionary, expected_player_id: String = "") -> bool:
|
||||||
|
for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"]:
|
||||||
|
if not payload.has(key):
|
||||||
|
return _reject("Assignment response is missing " + key)
|
||||||
|
if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not _valid_nonnegative_integer(payload["slot"]) or not payload["expires_at"] is String or not _valid_nonnegative_integer(payload["protocol_version"]) or not payload["transport"] is String or not payload["endpoint"] is String or not payload["join_authorisation"] is String:
|
||||||
|
return _reject("Assignment response contains invalid types")
|
||||||
|
var next_match_id := String(payload["match_id"])
|
||||||
|
var next_server_id := String(payload["server_id"])
|
||||||
|
var next_transport := String(payload["transport"])
|
||||||
|
var next_endpoint := String(payload["endpoint"])
|
||||||
|
var next_player_id := String(payload["player_id"])
|
||||||
|
var next_expires_at := String(payload["expires_at"])
|
||||||
|
if not is_valid_expiry_timestamp(next_expires_at):
|
||||||
|
return _reject("Assignment response contains invalid expiry")
|
||||||
|
var expiry_unix := Time.get_unix_time_from_datetime_string(next_expires_at)
|
||||||
|
if not is_valid_opaque_id(next_match_id) or not is_valid_opaque_id(next_server_id) or not is_valid_opaque_id(next_player_id) or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or not _valid_endpoint(next_endpoint) or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty():
|
||||||
|
return _reject("Assignment response contains invalid values")
|
||||||
|
match_id = next_match_id
|
||||||
|
server_id = next_server_id
|
||||||
|
slot = int(payload["slot"])
|
||||||
|
expires_at = next_expires_at
|
||||||
|
protocol_version = int(payload["protocol_version"])
|
||||||
|
transport = next_transport
|
||||||
|
endpoint = next_endpoint
|
||||||
|
join_authorisation = String(payload["join_authorisation"])
|
||||||
|
available = true
|
||||||
|
error_message = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_expiry_timestamp(value: String) -> bool:
|
||||||
|
if value.is_empty():
|
||||||
|
return false
|
||||||
|
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
||||||
|
return timestamp_pattern.search(value) != null
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_opaque_id(value: String) -> bool:
|
||||||
|
if value.length() < 16 or value.length() > 128:
|
||||||
|
return false
|
||||||
|
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||||
|
return resource_pattern.search(value) != null
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_endpoint(value: String) -> bool:
|
||||||
|
if value.is_empty() or value.contains("/") or value.contains("?") or value.contains("#"):
|
||||||
|
return false
|
||||||
|
var separator := value.rfind(":")
|
||||||
|
if separator <= 0 or separator >= value.length() - 1:
|
||||||
|
return false
|
||||||
|
var port := value.substr(separator + 1)
|
||||||
|
return port.is_valid_int() and int(port) >= 1 and int(port) <= 65535
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_nonnegative_integer(value: Variant) -> bool:
|
||||||
|
if value is int:
|
||||||
|
return int(value) >= 0
|
||||||
|
if value is float:
|
||||||
|
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _reject(reason: String) -> bool:
|
||||||
|
available = false
|
||||||
|
error_message = reason
|
||||||
|
return false
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Dependency-free audio foundation. Authored assets can replace these tones
|
||||||
|
# later without changing gameplay call sites or the multiplayer event flow.
|
||||||
|
|
||||||
|
const SAMPLE_RATE := 44100
|
||||||
|
const MAX_INTENSITY := 1.0
|
||||||
|
|
||||||
|
var enabled := true
|
||||||
|
var _engine_player: AudioStreamPlayer
|
||||||
|
var _engine_turbo := false
|
||||||
|
var _last_wall_scrape_ms := -1000
|
||||||
|
|
||||||
|
|
||||||
|
func bind_tree_buttons(root: Node) -> void:
|
||||||
|
if root == null:
|
||||||
|
return
|
||||||
|
for node in root.find_children("*", "BaseButton", true, false):
|
||||||
|
bind_button(node as BaseButton)
|
||||||
|
|
||||||
|
|
||||||
|
func bind_button(button: BaseButton) -> void:
|
||||||
|
if button == null:
|
||||||
|
return
|
||||||
|
var callback := Callable(self, "play_ui_click")
|
||||||
|
if not button.pressed.is_connected(callback):
|
||||||
|
button.pressed.connect(callback)
|
||||||
|
|
||||||
|
|
||||||
|
func play_ui_click() -> void:
|
||||||
|
_play_tone(880.0, 0.045, 0.10)
|
||||||
|
|
||||||
|
|
||||||
|
func play_countdown(count: int) -> void:
|
||||||
|
if count <= 0:
|
||||||
|
_play_tone(1046.5, 0.12, 0.18)
|
||||||
|
else:
|
||||||
|
_play_tone(countdown_frequency(count), 0.08, 0.14)
|
||||||
|
|
||||||
|
|
||||||
|
func play_impact(intensity: float) -> void:
|
||||||
|
var amount := clamp_intensity(intensity)
|
||||||
|
if amount <= 0.0:
|
||||||
|
return
|
||||||
|
_play_tone(150.0 + 180.0 * amount, 0.06 + 0.08 * amount, 0.08 + 0.18 * amount)
|
||||||
|
|
||||||
|
|
||||||
|
func play_wall_scrape(intensity: float) -> void:
|
||||||
|
var amount := clamp_intensity(intensity)
|
||||||
|
if amount <= 0.0:
|
||||||
|
return
|
||||||
|
var now_ms := Time.get_ticks_msec()
|
||||||
|
if now_ms - _last_wall_scrape_ms < 80:
|
||||||
|
return
|
||||||
|
_last_wall_scrape_ms = now_ms
|
||||||
|
_play_tone(110.0 + 90.0 * amount, 0.05 + 0.07 * amount, 0.05 + 0.10 * amount)
|
||||||
|
|
||||||
|
|
||||||
|
func play_goal() -> void:
|
||||||
|
_play_tone(523.25, 0.22, 0.22)
|
||||||
|
_play_tone(783.99, 0.30, 0.18)
|
||||||
|
|
||||||
|
|
||||||
|
func set_engine_state(thrust: float, turbo: bool) -> void:
|
||||||
|
var amount := clamp_intensity(thrust)
|
||||||
|
var rising_turbo := should_play_turbo_cue(_engine_turbo, turbo, amount)
|
||||||
|
if not enabled or amount <= 0.01:
|
||||||
|
stop_engine()
|
||||||
|
return
|
||||||
|
if rising_turbo:
|
||||||
|
_play_tone(260.0, 0.16, 0.16)
|
||||||
|
_engine_turbo = turbo
|
||||||
|
if _engine_player == null or not is_instance_valid(_engine_player):
|
||||||
|
_engine_player = AudioStreamPlayer.new()
|
||||||
|
_engine_player.stream = _engine_stream()
|
||||||
|
add_child(_engine_player)
|
||||||
|
_engine_player.play()
|
||||||
|
_engine_player.pitch_scale = engine_pitch(amount, turbo)
|
||||||
|
_engine_player.volume_db = linear_to_db(engine_volume(amount, turbo))
|
||||||
|
|
||||||
|
|
||||||
|
func stop_engine() -> void:
|
||||||
|
if _engine_player != null and is_instance_valid(_engine_player):
|
||||||
|
_engine_player.stop()
|
||||||
|
_engine_turbo = false
|
||||||
|
|
||||||
|
|
||||||
|
static func engine_pitch(thrust: float, turbo: bool) -> float:
|
||||||
|
var amount := clamp_intensity(thrust)
|
||||||
|
return 0.75 + amount * 0.55 + (0.30 if turbo and amount > 0.01 else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
static func engine_volume(thrust: float, turbo: bool) -> float:
|
||||||
|
var amount := clamp_intensity(thrust)
|
||||||
|
return clampf(0.015 + amount * 0.045 + (0.025 if turbo and amount > 0.01 else 0.0), 0.0, 0.1)
|
||||||
|
|
||||||
|
|
||||||
|
static func should_play_turbo_cue(previous_turbo: bool, turbo: bool, thrust: float) -> bool:
|
||||||
|
return turbo and not previous_turbo and clamp_intensity(thrust) > 0.01
|
||||||
|
|
||||||
|
|
||||||
|
static func clamp_intensity(value: float) -> float:
|
||||||
|
if not is_finite(value):
|
||||||
|
return 0.0
|
||||||
|
return clampf(value, 0.0, MAX_INTENSITY)
|
||||||
|
|
||||||
|
|
||||||
|
static func countdown_frequency(count: int) -> float:
|
||||||
|
return 440.0 + float(clampi(count, 1, 9)) * 55.0
|
||||||
|
|
||||||
|
|
||||||
|
func _play_tone(frequency: float, duration: float, volume: float) -> void:
|
||||||
|
if not enabled or frequency <= 0.0 or duration <= 0.0 or volume <= 0.0:
|
||||||
|
return
|
||||||
|
var stream := AudioStreamWAV.new()
|
||||||
|
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||||
|
stream.mix_rate = SAMPLE_RATE
|
||||||
|
stream.stereo = false
|
||||||
|
stream.data = _tone_data(frequency, duration, volume)
|
||||||
|
var player := AudioStreamPlayer.new()
|
||||||
|
player.stream = stream
|
||||||
|
add_child(player)
|
||||||
|
player.finished.connect(player.queue_free)
|
||||||
|
player.play()
|
||||||
|
|
||||||
|
|
||||||
|
func _engine_stream() -> AudioStreamWAV:
|
||||||
|
var stream := AudioStreamWAV.new()
|
||||||
|
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||||
|
stream.mix_rate = SAMPLE_RATE
|
||||||
|
stream.stereo = false
|
||||||
|
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||||
|
stream.data = _tone_data(92.0, 1.0, 0.65)
|
||||||
|
stream.loop_end = SAMPLE_RATE
|
||||||
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
func _tone_data(frequency: float, duration: float, volume: float) -> PackedByteArray:
|
||||||
|
var frames := maxi(1, int(duration * SAMPLE_RATE))
|
||||||
|
var data := PackedByteArray()
|
||||||
|
data.resize(frames * 2)
|
||||||
|
for index in frames:
|
||||||
|
var envelope := minf(1.0, float(index) / 256.0) * minf(1.0, float(frames - index) / 1024.0)
|
||||||
|
var sample := int(sin(TAU * frequency * float(index) / SAMPLE_RATE) * volume * envelope * 32767.0)
|
||||||
|
if sample < 0:
|
||||||
|
sample += 65536
|
||||||
|
data[index * 2] = sample & 0xff
|
||||||
|
data[index * 2 + 1] = (sample >> 8) & 0xff
|
||||||
|
return data
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Autoload: drops Engine.max_fps while the window is unfocused, so an idle
|
||||||
|
# background window doesn't keep rendering at whatever uncapped rate the
|
||||||
|
# hardware can hit. Independent of — and complementary to — the per-menu
|
||||||
|
# refresh-rate cap in main_menu.gd/settings_menu.gd, which only covers menu
|
||||||
|
# screens; this covers every scene, including gameplay.
|
||||||
|
|
||||||
|
const BACKGROUND_FPS := 30
|
||||||
|
|
||||||
|
# 0 means "uncapped"; also what we restore to if focus is lost before any
|
||||||
|
# menu/gameplay scene has had a chance to set its own cap.
|
||||||
|
var _foreground_max_fps := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
match what:
|
||||||
|
NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||||
|
_foreground_max_fps = Engine.max_fps
|
||||||
|
Engine.max_fps = BACKGROUND_FPS
|
||||||
|
NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||||
|
Engine.max_fps = _foreground_max_fps
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://kkge43vtwhyv
|
||||||
+73
-1
@@ -18,6 +18,63 @@ const MAX_SPEED := 32.0
|
|||||||
|
|
||||||
var _boundary: ArenaBoundary
|
var _boundary: ArenaBoundary
|
||||||
var _trail: GPUParticles3D
|
var _trail: GPUParticles3D
|
||||||
|
@onready var visual: Node3D = $Visual
|
||||||
|
|
||||||
|
var _pending_teleport: Transform3D
|
||||||
|
var _has_pending_teleport := false
|
||||||
|
var _pending_teleport_linear_velocity := Vector3.ZERO
|
||||||
|
var _pending_teleport_angular_velocity := Vector3.ZERO
|
||||||
|
var _pending_teleport_has_velocity := false
|
||||||
|
|
||||||
|
|
||||||
|
# Queues an authoritative teleport, applied at the top of the next
|
||||||
|
# _integrate_forces — the only Jolt-safe place to write state.transform
|
||||||
|
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
|
||||||
|
# physics step via set_deferred("global_transform", ...).
|
||||||
|
func queue_teleport(to: Transform3D) -> void:
|
||||||
|
_pending_teleport = to
|
||||||
|
_has_pending_teleport = true
|
||||||
|
_pending_teleport_has_velocity = false
|
||||||
|
|
||||||
|
|
||||||
|
# Kept parallel to Ship's network correction hook. A locally predicted ball
|
||||||
|
# must resume from the authoritative velocity after a correction; gameplay
|
||||||
|
# resets still deliberately use queue_teleport() and zero both velocities.
|
||||||
|
# The queued-but-not-yet-applied teleport target, or null when none is
|
||||||
|
# pending. queue_teleport() defers the actual write to the next
|
||||||
|
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
|
||||||
|
# in between — anything that needs to broadcast where a body is ABOUT to be
|
||||||
|
# (networked_match.gd's kickoff) must read this instead, or it ships the
|
||||||
|
# pre-reset position and corrects it a tick later.
|
||||||
|
func get_pending_teleport():
|
||||||
|
return _pending_teleport if _has_pending_teleport else null
|
||||||
|
|
||||||
|
|
||||||
|
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||||
|
_pending_teleport = to
|
||||||
|
_pending_teleport_linear_velocity = new_linear_velocity
|
||||||
|
_pending_teleport_angular_velocity = new_angular_velocity
|
||||||
|
_pending_teleport_has_velocity = true
|
||||||
|
_has_pending_teleport = true
|
||||||
|
|
||||||
|
|
||||||
|
# -1 = use the real linear_velocity (default; see _physics_process below).
|
||||||
|
# A frozen remote ball (Phase 4) holds zero velocity — Godot/Jolt zeroes and
|
||||||
|
# ignores velocity writes on frozen bodies — so the trail needs a
|
||||||
|
# presentation-only speed fed in from outside instead of reading physics
|
||||||
|
# state that will never reflect the ball's true remote motion.
|
||||||
|
var _visual_speed_override: float = -1.0
|
||||||
|
|
||||||
|
# Prediction correction hook: exactly like Ship's visual offset, but kept
|
||||||
|
# here so a locally predicted ball can move its collider to authority while
|
||||||
|
# the mesh catches up over a short presentation-only decay.
|
||||||
|
var net_visual_offset := Vector3.ZERO
|
||||||
|
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||||||
|
const MAX_NET_VISUAL_OFFSET := 0.4
|
||||||
|
|
||||||
|
|
||||||
|
func set_visual_speed(speed: float) -> void:
|
||||||
|
_visual_speed_override = speed
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -31,8 +88,15 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if net_visual_offset != Vector3.ZERO:
|
||||||
|
net_visual_offset = net_visual_offset.limit_length(MAX_NET_VISUAL_OFFSET)
|
||||||
|
net_visual_offset *= pow(NET_VISUAL_OFFSET_DECAY, _delta * 60.0)
|
||||||
|
if net_visual_offset.length_squared() < 0.0001:
|
||||||
|
net_visual_offset = Vector3.ZERO
|
||||||
|
visual.position = net_visual_offset
|
||||||
if _trail:
|
if _trail:
|
||||||
var speed_ratio := clampf(linear_velocity.length() / MAX_SPEED, 0.0, 1.0)
|
var speed := _visual_speed_override if _visual_speed_override >= 0.0 else linear_velocity.length()
|
||||||
|
var speed_ratio := clampf(speed / MAX_SPEED, 0.0, 1.0)
|
||||||
_trail.emitting = speed_ratio > 0.12
|
_trail.emitting = speed_ratio > 0.12
|
||||||
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
|
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
|
||||||
|
|
||||||
@@ -68,6 +132,14 @@ func _build_trail() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||||
|
if _has_pending_teleport:
|
||||||
|
_has_pending_teleport = false
|
||||||
|
state.transform = _pending_teleport
|
||||||
|
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||||
|
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||||
|
_pending_teleport_has_velocity = false
|
||||||
|
reset_physics_interpolation()
|
||||||
|
|
||||||
if _boundary:
|
if _boundary:
|
||||||
var pull := _boundary.get_surface_pull(
|
var pull := _boundary.get_surface_pull(
|
||||||
global_position, wall_pull_strength, wall_pull_range,
|
global_position, wall_pull_strength, wall_pull_range,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
class_name ConnectionLeaseClient
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||||
|
|
||||||
|
signal reconciliation_failed(reason: String)
|
||||||
|
|
||||||
|
const CLAIMED := "claimed"
|
||||||
|
const UNAVAILABLE := "unavailable"
|
||||||
|
const REJECTED := "rejected"
|
||||||
|
|
||||||
|
var _base_url := ""
|
||||||
|
var _workload_token := ""
|
||||||
|
var _match_id := ""
|
||||||
|
var _server_id := ""
|
||||||
|
var _pending: Array[Dictionary] = []
|
||||||
|
var _processing := false
|
||||||
|
|
||||||
|
|
||||||
|
func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
|
||||||
|
base_url = base_url.strip_edges().trim_suffix("/")
|
||||||
|
workload_token = workload_token.strip_edges()
|
||||||
|
if not valid_configuration(base_url, workload_token, match_id, server_id):
|
||||||
|
return false
|
||||||
|
_base_url = base_url
|
||||||
|
_workload_token = workload_token
|
||||||
|
_match_id = match_id
|
||||||
|
_server_id = server_id
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# Admission awaits one bounded request only. If the control plane is down, the
|
||||||
|
# same process may continue using its local generation and this exact event is
|
||||||
|
# retained ahead of every later disconnect/reconnect for ordered reconciliation.
|
||||||
|
func claim(player_id: String, expected_generation: int) -> Dictionary:
|
||||||
|
if not AssignmentState.is_valid_opaque_id(player_id) or expected_generation < 0:
|
||||||
|
return {"status": REJECTED}
|
||||||
|
var event := _connect_event(player_id, expected_generation)
|
||||||
|
if _processing or not _pending.is_empty():
|
||||||
|
if expected_generation == 0:
|
||||||
|
return {"status": REJECTED}
|
||||||
|
_pending.append(event)
|
||||||
|
_start_processing()
|
||||||
|
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
|
||||||
|
var response := await _send(event, true)
|
||||||
|
if String(response.get("status", "")) == UNAVAILABLE:
|
||||||
|
if expected_generation == 0:
|
||||||
|
return {"status": REJECTED}
|
||||||
|
_pending.append(event)
|
||||||
|
_start_processing()
|
||||||
|
return {"status": UNAVAILABLE, "generation": expected_generation + 1}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
func record_disconnect(player_id: String, generation: int) -> void:
|
||||||
|
if not AssignmentState.is_valid_opaque_id(player_id) or generation < 1:
|
||||||
|
return
|
||||||
|
_pending.append(_disconnect_event(player_id, generation))
|
||||||
|
_start_processing()
|
||||||
|
|
||||||
|
|
||||||
|
func _start_processing() -> void:
|
||||||
|
if _processing or _pending.is_empty() or not is_inside_tree():
|
||||||
|
return
|
||||||
|
_process_pending()
|
||||||
|
|
||||||
|
|
||||||
|
func _process_pending() -> void:
|
||||||
|
_processing = true
|
||||||
|
while not _pending.is_empty() and is_inside_tree():
|
||||||
|
var event := _pending[0]
|
||||||
|
var response := await _send(event)
|
||||||
|
var status := String(response.get("status", ""))
|
||||||
|
if status == CLAIMED:
|
||||||
|
_pending.pop_front()
|
||||||
|
continue
|
||||||
|
if status == REJECTED:
|
||||||
|
reconciliation_failed.emit("durable connection lease conflict")
|
||||||
|
_processing = false
|
||||||
|
return
|
||||||
|
await get_tree().create_timer(1.0).timeout
|
||||||
|
_processing = false
|
||||||
|
|
||||||
|
|
||||||
|
func _send(event: Dictionary, allow_recovery := false) -> Dictionary:
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.timeout = 1.0
|
||||||
|
add_child(request)
|
||||||
|
var operation := String(event["operation"])
|
||||||
|
var endpoint := "%s/v1/servers/%s/%s" % [_base_url, _server_id.uri_encode(), operation]
|
||||||
|
var start_error := request.request(endpoint, [
|
||||||
|
"Authorization: Bearer " + _workload_token,
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"Idempotency-Key: " + String(event["key"]),
|
||||||
|
], HTTPClient.METHOD_POST, JSON.stringify(event["payload"]))
|
||||||
|
if start_error != OK:
|
||||||
|
request.queue_free()
|
||||||
|
return {"status": UNAVAILABLE}
|
||||||
|
var raw: Array = await request.request_completed
|
||||||
|
request.queue_free()
|
||||||
|
return classify_response(operation, int(event["generation"]), int(raw[0]), int(raw[1]), raw[3], allow_recovery)
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_event(player_id: String, expected_generation: int) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"operation": "connect",
|
||||||
|
"generation": expected_generation,
|
||||||
|
"key": event_key(_match_id, player_id, "connect", expected_generation),
|
||||||
|
"payload": {"player_id": player_id, "expected_generation": expected_generation},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _disconnect_event(player_id: String, generation: int) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"operation": "disconnect",
|
||||||
|
"generation": generation,
|
||||||
|
"key": event_key(_match_id, player_id, "disconnect", generation),
|
||||||
|
"payload": {"player_id": player_id, "generation": generation},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func classify_response(operation: String, generation: int, request_result: int, response_code: int, body: PackedByteArray, allow_recovery := false) -> Dictionary:
|
||||||
|
if request_result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code == 429 or response_code >= 500:
|
||||||
|
return {"status": UNAVAILABLE}
|
||||||
|
if operation == "disconnect" and response_code == 204:
|
||||||
|
return {"status": CLAIMED, "generation": generation}
|
||||||
|
if operation == "connect" and response_code == 200:
|
||||||
|
var decoded = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
if decoded is Dictionary and _valid_generation(decoded.get("generation")):
|
||||||
|
var claimed_generation := int(decoded["generation"])
|
||||||
|
if claimed_generation == generation + 1 or (allow_recovery and generation == 0 and claimed_generation > 1):
|
||||||
|
return {"status": CLAIMED, "generation": claimed_generation}
|
||||||
|
return {"status": REJECTED}
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_generation(value: Variant) -> bool:
|
||||||
|
if value is int:
|
||||||
|
return int(value) >= 1
|
||||||
|
if value is float:
|
||||||
|
return is_finite(float(value)) and float(value) >= 1.0 and float(value) == floor(float(value)) and float(value) <= 9007199254740991.0
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
static func event_key(match_id: String, player_id: String, operation: String, generation: int) -> String:
|
||||||
|
return "server-lease-" + (match_id + "\n" + player_id + "\n" + operation + "\n" + str(generation)).sha256_text()
|
||||||
|
|
||||||
|
|
||||||
|
static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool:
|
||||||
|
if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#") or base_url.contains("@"):
|
||||||
|
return false
|
||||||
|
if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"):
|
||||||
|
return false
|
||||||
|
return AssignmentState.is_valid_opaque_id(match_id) and AssignmentState.is_valid_opaque_id(server_id)
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Authenticated HTTP boundary for matchmaking. ENet/Steam carries the match
|
||||||
|
# itself; this client only handles queue/proposal control-plane state.
|
||||||
|
|
||||||
|
signal request_succeeded(operation: String, payload: Dictionary)
|
||||||
|
signal request_failed(operation: String, http_code: int, detail: String)
|
||||||
|
signal session_expired()
|
||||||
|
signal probe_challenge_received(region: String, nonce_base64: String)
|
||||||
|
signal probe_recorded(region: String, server_rtt_ms: int)
|
||||||
|
signal session_changed(player_id: String)
|
||||||
|
signal websocket_event(event: Dictionary)
|
||||||
|
signal websocket_status_changed(status: String)
|
||||||
|
signal assignment_connection_started(assignment: AssignmentState)
|
||||||
|
signal assignment_connection_failed(detail: String)
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
|
||||||
|
# Release builds must point at the real control plane rather than a developer's
|
||||||
|
# loopback. The environment variable is read at startup so the same binary can
|
||||||
|
# be pointed at a staging or production endpoint without a rebuild.
|
||||||
|
const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL"
|
||||||
|
const PERSIST_PATH := "user://matchmaking_state.cfg"
|
||||||
|
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
|
||||||
|
|
||||||
|
var base_url := DEFAULT_BASE_URL
|
||||||
|
var access_token := ""
|
||||||
|
var auth_expired := false
|
||||||
|
var player_id := ""
|
||||||
|
var session_expires_at := ""
|
||||||
|
var state: MatchmakingState
|
||||||
|
var ranked_profile: RankedProfileState
|
||||||
|
var assignment: AssignmentState
|
||||||
|
|
||||||
|
var _request: HTTPRequest
|
||||||
|
var _operation := ""
|
||||||
|
var _last_queue_create: Dictionary = {}
|
||||||
|
var _last_mutation: Dictionary = {}
|
||||||
|
var _last_mutation_retryable := false
|
||||||
|
var _websocket: WebSocketPeer
|
||||||
|
var _websocket_status := "DISCONNECTED"
|
||||||
|
var _websocket_retry_seconds := 0.0
|
||||||
|
var _websocket_backoff := 1.0
|
||||||
|
var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||||
|
var _pending_proposal_id := ""
|
||||||
|
var _pending_assignment_match_id := ""
|
||||||
|
var _pending_resync_resource_id := ""
|
||||||
|
# The final wiring step of the matchmaking pipeline: once state.phase reaches
|
||||||
|
# ASSIGNED, the client must actually start the game transport. connect_to_assignment()
|
||||||
|
# already existed with correct validation/signal behavior, but nothing ever
|
||||||
|
# called it -- a player would sit on "Your match server is ready" forever.
|
||||||
|
# These two fields defer the connect attempt until the assignment fetch
|
||||||
|
# (triggered independently, earlier, by ASSIGNMENT_READY) has actually
|
||||||
|
# completed, and prevent a duplicate/replayed ASSIGNED update from firing a
|
||||||
|
# second connection attempt for the same match.
|
||||||
|
var _pending_connect_match_id := ""
|
||||||
|
var _connect_attempted_match_id := ""
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
state = MatchmakingState.new()
|
||||||
|
ranked_profile = RankedProfileState.new()
|
||||||
|
assignment = AssignmentState.new()
|
||||||
|
_load_persisted_state()
|
||||||
|
state.changed.connect(_persist_state)
|
||||||
|
_request = HTTPRequest.new()
|
||||||
|
_request.timeout = 10.0
|
||||||
|
add_child(_request)
|
||||||
|
_request.request_completed.connect(_on_request_completed)
|
||||||
|
state.resync_required.connect(_on_resync_required)
|
||||||
|
_websocket = WebSocketPeer.new()
|
||||||
|
assignment_connection_failed.connect(_on_assignment_connection_failed)
|
||||||
|
NetworkManager.connection_failed.connect(_on_network_connection_failed)
|
||||||
|
|
||||||
|
|
||||||
|
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
||||||
|
# synchronous failures (assignment missing/expired, invalid endpoint,
|
||||||
|
# NetworkManager.join() erroring immediately) previously only emitted
|
||||||
|
# assignment_connection_failed -- a signal nothing in the client actually
|
||||||
|
# listened to. state.phase would stay stuck at ASSIGNED, the UI would keep
|
||||||
|
# showing "Your match server is ready" forever, and there was no way back to
|
||||||
|
# a fresh search.
|
||||||
|
func _on_assignment_connection_failed(detail: String) -> void:
|
||||||
|
state.fail(detail)
|
||||||
|
|
||||||
|
|
||||||
|
# The likelier real-world failure than the synchronous one above:
|
||||||
|
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
||||||
|
# actual ENet handshake fails asynchronously later -- unreachable server,
|
||||||
|
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
||||||
|
# main_menu.gd's own _on_connection_failed exists to cover for the direct-join
|
||||||
|
# flow (see its header comment); nothing covered it for a matchmaking-driven
|
||||||
|
# connect. Guarded to CONNECTING so this never reacts to an unrelated
|
||||||
|
# connection_failed, such as one belonging to main_menu.gd's own direct join.
|
||||||
|
func _on_network_connection_failed() -> void:
|
||||||
|
if state.phase == MatchmakingState.CONNECTING:
|
||||||
|
state.fail("Unable to connect to the match server")
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not auth_expired and is_session_expired(session_expires_at):
|
||||||
|
_expire_session()
|
||||||
|
if _websocket == null:
|
||||||
|
return
|
||||||
|
_websocket.poll()
|
||||||
|
var ready_state := _websocket.get_ready_state()
|
||||||
|
if ready_state == WebSocketPeer.STATE_OPEN:
|
||||||
|
_websocket_retry_seconds = 0.0
|
||||||
|
_websocket_backoff = 1.0
|
||||||
|
_set_websocket_status("CONNECTED")
|
||||||
|
while _websocket.get_available_packet_count() > 0:
|
||||||
|
_handle_websocket_packet(_websocket.get_packet())
|
||||||
|
elif ready_state == WebSocketPeer.STATE_CONNECTING:
|
||||||
|
_set_websocket_status("CONNECTING")
|
||||||
|
elif ready_state == WebSocketPeer.STATE_CLOSED:
|
||||||
|
_set_websocket_status("DISCONNECTED")
|
||||||
|
if not auth_expired and is_valid_access_token(access_token):
|
||||||
|
_websocket_retry_seconds -= _delta
|
||||||
|
if _websocket_retry_seconds <= 0.0:
|
||||||
|
_websocket_retry_seconds = _websocket_backoff
|
||||||
|
_websocket_backoff = minf(_websocket_backoff * 2.0, 30.0)
|
||||||
|
connect_event_stream()
|
||||||
|
if not _pending_proposal_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||||
|
var proposal_id := _pending_proposal_id
|
||||||
|
_pending_proposal_id = ""
|
||||||
|
recover_proposal(proposal_id)
|
||||||
|
elif not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
||||||
|
var match_id := _pending_assignment_match_id
|
||||||
|
_pending_assignment_match_id = ""
|
||||||
|
fetch_assignment(match_id)
|
||||||
|
if not _pending_connect_match_id.is_empty() and _assignment_ready_for(_pending_connect_match_id):
|
||||||
|
var match_id := _pending_connect_match_id
|
||||||
|
_pending_connect_match_id = ""
|
||||||
|
_connect_attempted_match_id = match_id
|
||||||
|
connect_to_assignment()
|
||||||
|
_poll_authoritative_recovery(_delta)
|
||||||
|
|
||||||
|
|
||||||
|
# The assignment fetch (triggered independently by ASSIGNMENT_READY, which
|
||||||
|
# always precedes ASSIGNED) and the ASSIGNED transition that should start the
|
||||||
|
# transport can arrive in either order. This is the shared readiness check
|
||||||
|
# both _connect_when_assigned and the deferred _process retry above use.
|
||||||
|
func _assignment_ready_for(match_id: String) -> bool:
|
||||||
|
return assignment != null and assignment.available and assignment.match_id == match_id and _assignment_is_fresh(assignment)
|
||||||
|
|
||||||
|
|
||||||
|
# Starts (or defers, if the assignment fetch triggered by the earlier
|
||||||
|
# ASSIGNMENT_READY event hasn't completed yet) the game transport once the
|
||||||
|
# ticket-state machine reaches ASSIGNED. connect_to_assignment() itself
|
||||||
|
# already existed with full validation and failure signalling; nothing ever
|
||||||
|
# called it, so a player reaching "Your match server is ready" never actually
|
||||||
|
# connected. _connect_attempted_match_id guards against a duplicate/replayed
|
||||||
|
# ASSIGNED update firing a second connection attempt for the same match.
|
||||||
|
func _connect_when_assigned(match_id: String) -> void:
|
||||||
|
if state.phase != MatchmakingState.ASSIGNED or not is_valid_resource_id(match_id) or match_id == _connect_attempted_match_id:
|
||||||
|
return
|
||||||
|
if _assignment_ready_for(match_id):
|
||||||
|
_connect_attempted_match_id = match_id
|
||||||
|
connect_to_assignment()
|
||||||
|
else:
|
||||||
|
_pending_connect_match_id = match_id
|
||||||
|
|
||||||
|
|
||||||
|
# configured_base_url resolves the endpoint this build should use, preferring
|
||||||
|
# explicit configuration over the loopback development default.
|
||||||
|
static func configured_base_url() -> String:
|
||||||
|
var configured := OS.get_environment(BASE_URL_ENV).strip_edges()
|
||||||
|
if is_valid_base_url(configured):
|
||||||
|
return configured
|
||||||
|
return DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
# has_session reports whether matchmaking requests can be made at all. Without
|
||||||
|
# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request.
|
||||||
|
func has_session() -> bool:
|
||||||
|
return not access_token.is_empty() and not is_session_expired(session_expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
func configure(url: String, token: String) -> bool:
|
||||||
|
var normalized := url.strip_edges().trim_suffix("/")
|
||||||
|
var normalized_token := token.strip_edges()
|
||||||
|
if not is_valid_base_url(normalized) or not is_valid_access_token(normalized_token):
|
||||||
|
return false
|
||||||
|
base_url = normalized
|
||||||
|
access_token = normalized_token
|
||||||
|
session_expires_at = ""
|
||||||
|
auth_expired = false
|
||||||
|
if _websocket != null:
|
||||||
|
connect_event_stream()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func connect_event_stream() -> Error:
|
||||||
|
if not is_valid_access_token(access_token) or auth_expired or not is_valid_base_url(base_url):
|
||||||
|
return ERR_UNAUTHORIZED
|
||||||
|
var socket_url := websocket_url(base_url) + "/v1/events"
|
||||||
|
_websocket = WebSocketPeer.new()
|
||||||
|
# Godot 4.7 moved handshake headers onto WebSocketPeer; the second
|
||||||
|
# connect_to_url argument is TLSOptions, not an HTTP header array. Keep the
|
||||||
|
# bearer token in the authenticated handshake without putting it in the URL.
|
||||||
|
_websocket.handshake_headers = PackedStringArray(["Authorization: Bearer " + access_token])
|
||||||
|
var err := _websocket.connect_to_url(socket_url)
|
||||||
|
if err != OK:
|
||||||
|
_set_websocket_status("DISCONNECTED")
|
||||||
|
return err
|
||||||
|
_websocket_retry_seconds = 0.0
|
||||||
|
_set_websocket_status("CONNECTING")
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
func disconnect_event_stream() -> void:
|
||||||
|
if _websocket != null:
|
||||||
|
_websocket.close()
|
||||||
|
_websocket_retry_seconds = 0.0
|
||||||
|
_websocket_backoff = 1.0
|
||||||
|
_set_websocket_status("DISCONNECTED")
|
||||||
|
|
||||||
|
|
||||||
|
static func websocket_url(url: String) -> String:
|
||||||
|
if url.begins_with("https://"):
|
||||||
|
return "wss://" + url.trim_prefix("https://")
|
||||||
|
if url.begins_with("http://"):
|
||||||
|
return "ws://" + url.trim_prefix("http://")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error:
|
||||||
|
if not is_valid_resource_id(ticket_id) or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1:
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
if not state.begin_queue(ticket_id, playlist):
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
var key := _idempotency_key("queue")
|
||||||
|
_last_queue_create = {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version, "key": key}
|
||||||
|
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, key)
|
||||||
|
if err != OK:
|
||||||
|
state.fail("Could not start matchmaking: %s" % error_string(err))
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
# Regional latency probing. The backend issues a single-use nonce, the client
|
||||||
|
# echoes it back with its opaque platform location, and the backend derives the
|
||||||
|
# round trip from its own timestamps -- no client-measured latency is accepted.
|
||||||
|
#
|
||||||
|
# Until a ticket has RTT evidence for at least one region the matcher will not
|
||||||
|
# consider it (server/domain.validCandidate requires a non-empty map), so this
|
||||||
|
# has to complete before searching is meaningful.
|
||||||
|
const PROBE_REGIONS := ["EU", "NA"]
|
||||||
|
|
||||||
|
|
||||||
|
func request_probe_challenge(region: String) -> Error:
|
||||||
|
if not is_valid_probe_region(region):
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("probe_challenge_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s/challenge" % region, {}, "")
|
||||||
|
|
||||||
|
|
||||||
|
func submit_probe_answer(region: String, nonce_base64: String, opaque_location_base64: String) -> Error:
|
||||||
|
if not is_valid_probe_region(region) or nonce_base64.is_empty() or opaque_location_base64.is_empty():
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("probe_answer_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s" % region, {
|
||||||
|
"nonce": nonce_base64,
|
||||||
|
"opaque_location": opaque_location_base64,
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_probe_region(region: String) -> bool:
|
||||||
|
return region == "EU" or region == "NA"
|
||||||
|
|
||||||
|
|
||||||
|
# The platform location is opaque to us by design: the backend treats it as a
|
||||||
|
# blob and never derives placement from anything the client measured. Without a
|
||||||
|
# Steam runtime there is nothing to report, so send a stable non-empty marker
|
||||||
|
# rather than failing the probe -- the RTT is what actually matters and that is
|
||||||
|
# measured by the backend either way.
|
||||||
|
static func opaque_location_payload() -> String:
|
||||||
|
if Engine.has_singleton("Steam"):
|
||||||
|
var steam := Engine.get_singleton("Steam")
|
||||||
|
if steam.has_method("getLocalPingLocation"):
|
||||||
|
var location = steam.call("getLocalPingLocation")
|
||||||
|
if location is String and not String(location).is_empty():
|
||||||
|
return Marshalls.utf8_to_base64(String(location))
|
||||||
|
return Marshalls.utf8_to_base64("no-platform-ping-location")
|
||||||
|
|
||||||
|
|
||||||
|
func login_steam(web_api_ticket: String) -> Error:
|
||||||
|
if not is_valid_web_api_ticket(web_api_ticket):
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("steam_session", HTTPClient.METHOD_POST, "/v1/session/steam", {"web_api_ticket": web_api_ticket}, "")
|
||||||
|
|
||||||
|
|
||||||
|
func retry_queue_create() -> Error:
|
||||||
|
if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"):
|
||||||
|
return ERR_INVALID_DATA
|
||||||
|
var ticket_id := String(_last_queue_create["ticket_id"])
|
||||||
|
var playlist := String(_last_queue_create["playlist"])
|
||||||
|
if not state.begin_queue(ticket_id, playlist):
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": String(_last_queue_create["client_build"]), "protocol_version": int(_last_queue_create["protocol_version"])}, String(_last_queue_create["key"]))
|
||||||
|
if err != OK:
|
||||||
|
state.fail("Could not retry matchmaking: %s" % error_string(err))
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
func can_retry_queue_create() -> bool:
|
||||||
|
return not _last_queue_create.is_empty() and state.phase == MatchmakingState.FAILED and String(_last_queue_create.get("ticket_id", "")) == state.ticket_id
|
||||||
|
|
||||||
|
|
||||||
|
func retry_last_mutation() -> Error:
|
||||||
|
if not can_retry_last_mutation():
|
||||||
|
return ERR_INVALID_DATA
|
||||||
|
var request := _last_mutation.duplicate(true)
|
||||||
|
return _start_request(String(request["operation"]), int(request["method"]), String(request["path"]), request["payload"], String(request["key"]), int(request["expected_revision"]))
|
||||||
|
|
||||||
|
|
||||||
|
func can_retry_last_mutation() -> bool:
|
||||||
|
return _last_mutation_retryable and not _last_mutation.is_empty() and _operation.is_empty() and not auth_expired and is_valid_access_token(access_token)
|
||||||
|
|
||||||
|
|
||||||
|
func recover_queue(ticket_id: String) -> Error:
|
||||||
|
if not is_valid_resource_id(ticket_id):
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "")
|
||||||
|
|
||||||
|
|
||||||
|
func recover_proposal(proposal_id: String) -> Error:
|
||||||
|
if not is_valid_resource_id(proposal_id):
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "")
|
||||||
|
|
||||||
|
|
||||||
|
static func resync_target(resource_id: String, ticket_id: String, proposal_id: String, proposal_open: bool) -> String:
|
||||||
|
if resource_id == ticket_id and not ticket_id.is_empty():
|
||||||
|
return ticket_id
|
||||||
|
if resource_id == proposal_id and not proposal_id.is_empty():
|
||||||
|
return proposal_id if proposal_open else ticket_id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func fetch_ranked_profile() -> Error:
|
||||||
|
return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "")
|
||||||
|
|
||||||
|
|
||||||
|
func fetch_assignment(match_id: String) -> Error:
|
||||||
|
if not is_valid_resource_id(match_id) or player_id.is_empty():
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "")
|
||||||
|
|
||||||
|
|
||||||
|
# Starts the assigned game transport only after AssignmentState has validated
|
||||||
|
# the complete player-scoped manifest. The signed authorisation is passed to
|
||||||
|
# MatchNet's hello RPC, never appended to the endpoint URL or logged. Server
|
||||||
|
# admission remains authoritative; this method only owns the client-side
|
||||||
|
# readiness/transport boundary.
|
||||||
|
func connect_to_assignment() -> Error:
|
||||||
|
if assignment == null or not assignment.available or not _assignment_is_fresh(assignment):
|
||||||
|
var unavailable_detail := "Match assignment is unavailable or expired"
|
||||||
|
assignment_connection_failed.emit(unavailable_detail)
|
||||||
|
return ERR_UNAUTHORIZED
|
||||||
|
var endpoint := _split_assignment_endpoint(assignment.endpoint)
|
||||||
|
if endpoint.is_empty():
|
||||||
|
var invalid_detail := "Match assignment endpoint is invalid"
|
||||||
|
assignment_connection_failed.emit(invalid_detail)
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
var transport := NetworkManager.TRANSPORT_ENET if assignment.transport == "enet" else NetworkManager.TRANSPORT_STEAM
|
||||||
|
MatchNet.join_authorisation = assignment.join_authorisation
|
||||||
|
state.mark_connecting()
|
||||||
|
var err := NetworkManager.join(String(endpoint["host"]), int(endpoint["port"]), transport)
|
||||||
|
if err != OK:
|
||||||
|
MatchNet.join_authorisation = ""
|
||||||
|
assignment_connection_failed.emit("Unable to connect to match server")
|
||||||
|
return err
|
||||||
|
assignment_connection_started.emit(assignment)
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
static func _assignment_is_fresh(value: AssignmentState) -> bool:
|
||||||
|
if value == null or not AssignmentState.is_valid_expiry_timestamp(value.expires_at):
|
||||||
|
return false
|
||||||
|
var expiry := Time.get_unix_time_from_datetime_string(value.expires_at)
|
||||||
|
return expiry > Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
|
||||||
|
static func _split_assignment_endpoint(value: String) -> Dictionary:
|
||||||
|
if not AssignmentState._valid_endpoint(value):
|
||||||
|
return {}
|
||||||
|
var separator := value.rfind(":")
|
||||||
|
return {"host": value.substr(0, separator), "port": int(value.substr(separator + 1))}
|
||||||
|
|
||||||
|
|
||||||
|
func heartbeat(ticket_id: String, expected_revision: int) -> Error:
|
||||||
|
if not is_valid_resource_id(ticket_id) or expected_revision < 0:
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision)
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_queue(ticket_id: String, expected_revision: int) -> Error:
|
||||||
|
if not is_valid_resource_id(ticket_id) or expected_revision < 0 or not state.can_cancel():
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision)
|
||||||
|
|
||||||
|
|
||||||
|
func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error:
|
||||||
|
if not is_valid_resource_id(proposal_id) or expected_revision < 0:
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
var action := "accept" if accept else "decline"
|
||||||
|
return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision)
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_base_url(url: String) -> bool:
|
||||||
|
if url.is_empty() or url.contains(" ") or url.contains("\r") or url.contains("\n") or url.contains("?") or url.contains("#") or url.contains("@") or url.ends_with("/"):
|
||||||
|
return false
|
||||||
|
return url.begins_with("http://") or url.begins_with("https://")
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_web_api_ticket(ticket: String) -> bool:
|
||||||
|
return not ticket.is_empty() and ticket.length() <= 4096 and not ticket.contains("\r") and not ticket.contains("\n")
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_access_token(token: String) -> bool:
|
||||||
|
var separator := token.find(":")
|
||||||
|
return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n")
|
||||||
|
|
||||||
|
|
||||||
|
static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool:
|
||||||
|
if expires_at.is_empty():
|
||||||
|
return false
|
||||||
|
if not is_valid_rfc3339_timestamp(expires_at):
|
||||||
|
return true
|
||||||
|
var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at)
|
||||||
|
if expiry_unix < 0:
|
||||||
|
return true
|
||||||
|
var current_unix := now_unix
|
||||||
|
if current_unix < 0:
|
||||||
|
current_unix = int(Time.get_unix_time_from_system())
|
||||||
|
return expiry_unix <= current_unix
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_session_response(payload: Dictionary) -> bool:
|
||||||
|
if not payload.has("expires_at") or not payload["expires_at"] is String:
|
||||||
|
return false
|
||||||
|
var expires_at := String(payload["expires_at"])
|
||||||
|
return is_valid_rfc3339_timestamp(expires_at) and not is_session_expired(expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_rfc3339_timestamp(value: String) -> bool:
|
||||||
|
if value.is_empty():
|
||||||
|
return false
|
||||||
|
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
||||||
|
if timestamp_pattern.search(value) == null:
|
||||||
|
return false
|
||||||
|
var year := int(value.substr(0, 4))
|
||||||
|
var month := int(value.substr(5, 2))
|
||||||
|
var day := int(value.substr(8, 2))
|
||||||
|
var hour := int(value.substr(11, 2))
|
||||||
|
var minute := int(value.substr(14, 2))
|
||||||
|
var second := int(value.substr(17, 2))
|
||||||
|
if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59:
|
||||||
|
return false
|
||||||
|
var days_in_month := [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
var leap_year := year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
||||||
|
if leap_year:
|
||||||
|
days_in_month[1] = 29
|
||||||
|
if day < 1 or day > days_in_month[month - 1]:
|
||||||
|
return false
|
||||||
|
var timezone_index := value.find("+", 19)
|
||||||
|
if timezone_index < 0:
|
||||||
|
timezone_index = value.find("-", 19)
|
||||||
|
if timezone_index >= 0:
|
||||||
|
var offset_hour := int(value.substr(timezone_index + 1, 2))
|
||||||
|
var offset_minute := int(value.substr(timezone_index + 4, 2))
|
||||||
|
if offset_hour > 23 or offset_minute > 59:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_resource_id(value: String) -> bool:
|
||||||
|
if value.length() < 16 or value.length() > 128:
|
||||||
|
return false
|
||||||
|
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||||
|
return resource_pattern.search(value) != null
|
||||||
|
|
||||||
|
|
||||||
|
static func is_retryable_mutation_response(response_code: int) -> bool:
|
||||||
|
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
|
||||||
|
|
||||||
|
|
||||||
|
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
|
||||||
|
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
||||||
|
var result := payload.duplicate(true)
|
||||||
|
for pair in [["enqueued_at", "enqueued_at_unix"], ["expires_at", "expires_at_unix"]]:
|
||||||
|
var source_key: String = pair[0]
|
||||||
|
var target_key: String = pair[1]
|
||||||
|
if not result.has(source_key):
|
||||||
|
continue
|
||||||
|
if not result[source_key] is String or not is_valid_rfc3339_timestamp(String(result[source_key])):
|
||||||
|
result[target_key] = -1
|
||||||
|
else:
|
||||||
|
result[target_key] = Time.get_unix_time_from_datetime_string(String(result[source_key]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
static func normalize_proposal(payload: Dictionary) -> Dictionary:
|
||||||
|
var result := payload.duplicate(true)
|
||||||
|
if not result.has("expires_at"):
|
||||||
|
return result
|
||||||
|
if not result["expires_at"] is String or not is_valid_rfc3339_timestamp(String(result["expires_at"])):
|
||||||
|
result["expires_at_unix"] = -1
|
||||||
|
else:
|
||||||
|
result["expires_at_unix"] = int(Time.get_unix_time_from_datetime_string(String(result["expires_at"])))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
|
||||||
|
if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url):
|
||||||
|
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
|
||||||
|
if operation != "steam_session" and access_token.is_empty():
|
||||||
|
return ERR_UNAUTHORIZED
|
||||||
|
if operation != "steam_session" and is_session_expired(session_expires_at):
|
||||||
|
_expire_session()
|
||||||
|
return ERR_UNAUTHORIZED
|
||||||
|
var headers := PackedStringArray(["Accept: application/json"])
|
||||||
|
if operation != "steam_session":
|
||||||
|
headers.append("Authorization: Bearer " + access_token)
|
||||||
|
if not idempotency_key.is_empty():
|
||||||
|
headers.append("Idempotency-Key: " + idempotency_key)
|
||||||
|
if expected_revision >= 0:
|
||||||
|
headers.append("If-Match-Revision: %d" % expected_revision)
|
||||||
|
var body := "" if payload.is_empty() else JSON.stringify(payload)
|
||||||
|
_operation = operation
|
||||||
|
var err := _request.request(base_url + path, headers, method, body)
|
||||||
|
if err != OK:
|
||||||
|
_operation = ""
|
||||||
|
return err
|
||||||
|
if not idempotency_key.is_empty():
|
||||||
|
_last_mutation = {"operation": operation, "method": method, "path": path, "payload": payload.duplicate(true), "key": idempotency_key, "expected_revision": expected_revision}
|
||||||
|
_last_mutation_retryable = false
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
func _expire_session() -> void:
|
||||||
|
if auth_expired:
|
||||||
|
return
|
||||||
|
access_token = ""
|
||||||
|
auth_expired = true
|
||||||
|
disconnect_event_stream()
|
||||||
|
state.fail("Session expired; sign in again")
|
||||||
|
ranked_profile.set_error("Session expired; sign in again")
|
||||||
|
session_expired.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
|
var operation := _operation
|
||||||
|
_operation = ""
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
||||||
|
if operation == "ranked_profile":
|
||||||
|
ranked_profile.set_error("Ranked profile request failed")
|
||||||
|
elif operation == "queue_create":
|
||||||
|
state.fail("Control-plane request failed")
|
||||||
|
elif operation == "queue_recover" or operation == "proposal_recover":
|
||||||
|
state.set_notice("Could not refresh matchmaking state; retrying")
|
||||||
|
else:
|
||||||
|
state.set_notice("Control-plane request failed; retrying is safe")
|
||||||
|
request_failed.emit(operation, response_code, "network error")
|
||||||
|
return
|
||||||
|
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
if not parsed is Dictionary:
|
||||||
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
||||||
|
if operation == "ranked_profile":
|
||||||
|
ranked_profile.set_error("Ranked profile returned invalid JSON")
|
||||||
|
elif operation == "queue_create":
|
||||||
|
state.fail("Control-plane returned invalid JSON")
|
||||||
|
elif operation == "queue_recover" or operation == "proposal_recover":
|
||||||
|
state.set_notice("Could not refresh matchmaking state; retrying")
|
||||||
|
else:
|
||||||
|
state.set_notice("Control-plane returned invalid JSON; retrying is safe")
|
||||||
|
request_failed.emit(operation, response_code, "invalid JSON")
|
||||||
|
return
|
||||||
|
if response_code < 200 or response_code >= 300:
|
||||||
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
|
||||||
|
var detail := String(parsed.get("error", "request rejected"))
|
||||||
|
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
|
||||||
|
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
|
||||||
|
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
|
||||||
|
access_token = ""
|
||||||
|
auth_expired = true
|
||||||
|
disconnect_event_stream()
|
||||||
|
state.fail("Session expired; sign in again")
|
||||||
|
ranked_profile.set_error("Session expired; sign in again")
|
||||||
|
session_expired.emit()
|
||||||
|
elif response_code == HTTPClient.RESPONSE_GONE and operation == "queue_recover":
|
||||||
|
state.expire("Queue ticket expired")
|
||||||
|
elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE:
|
||||||
|
state.set_notice("Matchmaking is temporarily unavailable; retrying is safe")
|
||||||
|
elif response_code == HTTPClient.RESPONSE_UPGRADE_REQUIRED and operation == "queue_create":
|
||||||
|
# Distinct from the generic queue_create failure below: retrying
|
||||||
|
# with the same client build can never succeed, so the retry
|
||||||
|
# offer must not be shown (can_retry_queue_create() checks
|
||||||
|
# _last_queue_create; clearing it here suppresses "Retry Search").
|
||||||
|
_last_queue_create = {}
|
||||||
|
state.fail("Your client is out of date -- please update to continue searching")
|
||||||
|
elif operation == "ranked_profile":
|
||||||
|
ranked_profile.set_error(detail)
|
||||||
|
elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"):
|
||||||
|
state.fail("Matchmaking record is no longer available")
|
||||||
|
elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover":
|
||||||
|
state.fail(detail)
|
||||||
|
else:
|
||||||
|
state.set_notice(detail)
|
||||||
|
request_failed.emit(operation, response_code, detail)
|
||||||
|
if recover_proposal_after_conflict:
|
||||||
|
_pending_resync_resource_id = state.proposal_id
|
||||||
|
call_deferred("_run_pending_resync")
|
||||||
|
if recover_queue_after_conflict:
|
||||||
|
_pending_resync_resource_id = state.ticket_id
|
||||||
|
call_deferred("_run_pending_resync")
|
||||||
|
return
|
||||||
|
var payload: Dictionary = parsed
|
||||||
|
_last_mutation_retryable = false
|
||||||
|
if operation == "steam_session":
|
||||||
|
var returned_token := String(payload.get("access_token", ""))
|
||||||
|
var returned_player_id := String(payload.get("player_id", ""))
|
||||||
|
if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token) or not is_valid_session_response(payload):
|
||||||
|
request_failed.emit(operation, response_code, "invalid session response")
|
||||||
|
return
|
||||||
|
player_id = returned_player_id
|
||||||
|
access_token = returned_token
|
||||||
|
auth_expired = false
|
||||||
|
session_expires_at = String(payload.get("expires_at", ""))
|
||||||
|
connect_event_stream()
|
||||||
|
session_changed.emit(player_id)
|
||||||
|
elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel":
|
||||||
|
if not _valid_queue_response(payload):
|
||||||
|
state.fail("Queue response contains invalid contract data")
|
||||||
|
request_failed.emit(operation, response_code, "invalid queue response")
|
||||||
|
return
|
||||||
|
if operation == "queue_create":
|
||||||
|
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
|
||||||
|
elif operation.begins_with("proposal_"):
|
||||||
|
if not _valid_proposal_response(payload):
|
||||||
|
state.fail("Proposal response contains an invalid proposal identifier")
|
||||||
|
request_failed.emit(operation, response_code, "invalid proposal identifier")
|
||||||
|
return
|
||||||
|
if operation.begins_with("queue_"):
|
||||||
|
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
|
||||||
|
_queue_proposal_if_ready(payload)
|
||||||
|
_queue_assignment_if_ready(payload)
|
||||||
|
_connect_when_assigned(String(payload.get("match_id", "")))
|
||||||
|
elif operation.begins_with("proposal_"):
|
||||||
|
state.apply_proposal_update(normalize_proposal(payload))
|
||||||
|
elif operation == "ranked_profile":
|
||||||
|
if not ranked_profile.apply(payload):
|
||||||
|
request_failed.emit(operation, response_code, ranked_profile.error_message)
|
||||||
|
return
|
||||||
|
elif operation == "assignment":
|
||||||
|
if not assignment.apply(payload, player_id):
|
||||||
|
request_failed.emit(operation, response_code, assignment.error_message)
|
||||||
|
return
|
||||||
|
elif operation.begins_with("probe_challenge_"):
|
||||||
|
# Answer immediately: the nonce is single-use and short-lived, and the
|
||||||
|
# interval to this answer is exactly what the backend measures.
|
||||||
|
var challenge_region := operation.trim_prefix("probe_challenge_")
|
||||||
|
var nonce := String(payload.get("nonce", ""))
|
||||||
|
if nonce.is_empty():
|
||||||
|
request_failed.emit(operation, response_code, "probe challenge did not include a nonce")
|
||||||
|
return
|
||||||
|
probe_challenge_received.emit(challenge_region, nonce)
|
||||||
|
elif operation.begins_with("probe_answer_"):
|
||||||
|
probe_recorded.emit(operation.trim_prefix("probe_answer_"), int(payload.get("server_rtt_ms", -1)))
|
||||||
|
request_succeeded.emit(operation, payload)
|
||||||
|
if not _pending_resync_resource_id.is_empty():
|
||||||
|
call_deferred("_run_pending_resync")
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_websocket_packet(packet: PackedByteArray) -> void:
|
||||||
|
var parsed = JSON.parse_string(packet.get_string_from_utf8())
|
||||||
|
if not parsed is Dictionary or not _valid_websocket_event(parsed):
|
||||||
|
websocket_status_changed.emit("INVALID_EVENT")
|
||||||
|
return
|
||||||
|
var event: Dictionary = parsed
|
||||||
|
websocket_event.emit(event)
|
||||||
|
var event_name := String(event["event"])
|
||||||
|
if event_name == "state_changed":
|
||||||
|
# Allocation and match lifecycle rows are keyed by match ID, not ticket
|
||||||
|
# ID. Recover the owner-scoped ticket projection instead of feeding the
|
||||||
|
# match revision/resource into the ticket reducer. ASSIGNMENT_READY also
|
||||||
|
# carries the durable lookup key, so the assignment fetch can follow the
|
||||||
|
# recovery request without depending on a circular assignment_changed
|
||||||
|
# notification from the assignment GET itself.
|
||||||
|
if event.has("match_id"):
|
||||||
|
var match_id := String(event["match_id"])
|
||||||
|
_on_resync_required(state.ticket_id)
|
||||||
|
if String(event["state"]) == "ASSIGNMENT_READY":
|
||||||
|
_pending_assignment_match_id = match_id
|
||||||
|
return
|
||||||
|
var update := event.duplicate(true)
|
||||||
|
update["ticket_id"] = String(event["resource_id"])
|
||||||
|
if not state.apply_ticket_update(update):
|
||||||
|
return
|
||||||
|
elif event_name == "proposal_changed":
|
||||||
|
var proposal_update := event.duplicate(true)
|
||||||
|
proposal_update["proposal_id"] = String(event["resource_id"])
|
||||||
|
if state.prepare_proposal_recovery(String(proposal_update["proposal_id"])):
|
||||||
|
state.apply_proposal_update(proposal_update)
|
||||||
|
elif event_name == "assignment_changed":
|
||||||
|
state.mark_assignment_ready()
|
||||||
|
_pending_assignment_match_id = String(event["match_id"])
|
||||||
|
elif event_name == "error":
|
||||||
|
state.set_notice("Control-plane error: %s" % String(event["code"]))
|
||||||
|
_on_resync_required(String(event["resource_id"]))
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_websocket_event(event: Dictionary) -> bool:
|
||||||
|
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
|
||||||
|
return false
|
||||||
|
if not event.has("revision") or not _valid_revision(event["revision"]):
|
||||||
|
return false
|
||||||
|
if not event.has("resource_id") or not event["resource_id"] is String or not is_valid_resource_id(String(event["resource_id"])):
|
||||||
|
return false
|
||||||
|
if not event.has("occurred_at") or not event["occurred_at"] is String or not is_valid_rfc3339_timestamp(String(event["occurred_at"])):
|
||||||
|
return false
|
||||||
|
var event_name := String(event["event"])
|
||||||
|
if event_name == "assignment_changed":
|
||||||
|
return event.has("match_id") and event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and event.has("server_id") and event["server_id"] is String and is_valid_resource_id(String(event["server_id"]))
|
||||||
|
if event_name == "error":
|
||||||
|
return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]
|
||||||
|
if event_name == "state_changed":
|
||||||
|
if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||||
|
return false
|
||||||
|
if event.has("match_id"):
|
||||||
|
return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"])
|
||||||
|
return true
|
||||||
|
if event_name == "proposal_changed":
|
||||||
|
return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_revision(value: Variant) -> bool:
|
||||||
|
if value is int:
|
||||||
|
return int(value) >= 0
|
||||||
|
if value is float:
|
||||||
|
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
|
||||||
|
return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key]))
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_queue_response(payload: Dictionary) -> bool:
|
||||||
|
for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]:
|
||||||
|
if not payload.has(key):
|
||||||
|
return false
|
||||||
|
if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"):
|
||||||
|
return false
|
||||||
|
if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]:
|
||||||
|
return false
|
||||||
|
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||||
|
return false
|
||||||
|
if payload.has("match_id"):
|
||||||
|
if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])):
|
||||||
|
return false
|
||||||
|
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
|
||||||
|
return false
|
||||||
|
if payload.has("proposal_id"):
|
||||||
|
if not payload["proposal_id"] is String or not is_valid_resource_id(String(payload["proposal_id"])):
|
||||||
|
return false
|
||||||
|
if String(payload["state"]) != "PROPOSED":
|
||||||
|
return false
|
||||||
|
if payload.has("match_id") and payload.has("proposal_id"):
|
||||||
|
return false
|
||||||
|
if not _valid_revision(payload["revision"]):
|
||||||
|
return false
|
||||||
|
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
|
||||||
|
|
||||||
|
|
||||||
|
func _queue_assignment_if_ready(payload: Dictionary) -> void:
|
||||||
|
if String(payload.get("state", "")) != "ASSIGNMENT_READY":
|
||||||
|
return
|
||||||
|
var match_id := String(payload.get("match_id", ""))
|
||||||
|
if is_valid_resource_id(match_id):
|
||||||
|
_pending_assignment_match_id = match_id
|
||||||
|
|
||||||
|
|
||||||
|
func _queue_proposal_if_ready(payload: Dictionary) -> void:
|
||||||
|
if String(payload.get("state", "")) != "PROPOSED":
|
||||||
|
return
|
||||||
|
var proposal_id := String(payload.get("proposal_id", ""))
|
||||||
|
if is_valid_resource_id(proposal_id) and state.prepare_proposal_recovery(proposal_id):
|
||||||
|
_pending_proposal_id = proposal_id
|
||||||
|
|
||||||
|
|
||||||
|
func _poll_authoritative_recovery(delta: float) -> void:
|
||||||
|
if auth_expired or not is_valid_access_token(access_token) or state.ticket_id.is_empty() or state.phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]:
|
||||||
|
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||||
|
return
|
||||||
|
_authoritative_recovery_seconds -= maxf(0.0, delta)
|
||||||
|
if _authoritative_recovery_seconds > 0.0 or not _operation.is_empty():
|
||||||
|
return
|
||||||
|
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
||||||
|
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
||||||
|
_run_resync(resource_id)
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
||||||
|
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
|
||||||
|
return false
|
||||||
|
var participants: Array = payload["participants"]
|
||||||
|
if participants.size() < 2 or participants.size() > 6:
|
||||||
|
return false
|
||||||
|
var seen := {}
|
||||||
|
for participant in participants:
|
||||||
|
if not participant is Dictionary:
|
||||||
|
return false
|
||||||
|
if not participant.has("player_id") or not participant["player_id"] is String or not is_valid_resource_id(String(participant["player_id"])) or seen.has(String(participant["player_id"])):
|
||||||
|
return false
|
||||||
|
if not participant.has("response") or not participant["response"] is String or not String(participant["response"]) in ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]:
|
||||||
|
return false
|
||||||
|
if not participant.has("team") or not participant.has("slot") or not _valid_revision(participant["team"]) or not _valid_revision(participant["slot"]):
|
||||||
|
return false
|
||||||
|
var team := int(participant["team"])
|
||||||
|
var slot := int(participant["slot"])
|
||||||
|
if team > 1 or slot > 5 or slot / 3 != team:
|
||||||
|
return false
|
||||||
|
seen[String(participant["player_id"])] = true
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _on_resync_required(resource_id: String) -> void:
|
||||||
|
if not _operation.is_empty():
|
||||||
|
_pending_resync_resource_id = resource_id
|
||||||
|
return
|
||||||
|
_run_resync(resource_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _run_pending_resync() -> void:
|
||||||
|
if not _operation.is_empty() or _pending_resync_resource_id.is_empty():
|
||||||
|
return
|
||||||
|
var resource_id := _pending_resync_resource_id
|
||||||
|
_pending_resync_resource_id = ""
|
||||||
|
_run_resync(resource_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _run_resync(resource_id: String) -> void:
|
||||||
|
var target := resync_target(resource_id, state.ticket_id, state.proposal_id, state.has_open_proposal())
|
||||||
|
if target == state.ticket_id and not state.ticket_id.is_empty():
|
||||||
|
recover_queue(state.ticket_id)
|
||||||
|
elif target == state.proposal_id and not state.proposal_id.is_empty():
|
||||||
|
recover_proposal(state.proposal_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_websocket_status(status: String) -> void:
|
||||||
|
if _websocket_status == status:
|
||||||
|
return
|
||||||
|
_websocket_status = status
|
||||||
|
websocket_status_changed.emit(status)
|
||||||
|
if status == "CONNECTED":
|
||||||
|
if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
|
||||||
|
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
||||||
|
if _operation.is_empty():
|
||||||
|
_run_resync(resource_id)
|
||||||
|
else:
|
||||||
|
# A reconnect must not lose its authoritative recovery merely because
|
||||||
|
# the previous mutation has not acknowledged yet. The deferred path
|
||||||
|
# runs after that request completes and avoids an ERR_BUSY drop.
|
||||||
|
_pending_resync_resource_id = resource_id
|
||||||
|
|
||||||
|
|
||||||
|
func _idempotency_key(prefix: String) -> String:
|
||||||
|
return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())]
|
||||||
|
|
||||||
|
|
||||||
|
func _persist_state(snapshot: Dictionary) -> void:
|
||||||
|
var config := ConfigFile.new()
|
||||||
|
config.set_value("matchmaking", "snapshot", JSON.stringify(snapshot))
|
||||||
|
config.save(PERSIST_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_persisted_state() -> void:
|
||||||
|
var config := ConfigFile.new()
|
||||||
|
if config.load(PERSIST_PATH) != OK:
|
||||||
|
return
|
||||||
|
var raw = config.get_value("matchmaking", "snapshot", "")
|
||||||
|
if not raw is String or String(raw).is_empty():
|
||||||
|
return
|
||||||
|
var parsed = JSON.parse_string(String(raw))
|
||||||
|
if parsed is Dictionary and not state.restore_snapshot(parsed):
|
||||||
|
state.fail("Saved matchmaking state is invalid")
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
extends ScrollContainer
|
||||||
|
|
||||||
|
# Settings screen's Controls tab: rebinds every action in InputSettings.ACTIONS
|
||||||
|
# for either device, toggles invert-pitch, and resets to the project.godot
|
||||||
|
# defaults. InputSettings owns the bindings themselves and their persistence;
|
||||||
|
# this script is only the editor for them, and deliberately keeps
|
||||||
|
# settings_menu.gd video-only.
|
||||||
|
#
|
||||||
|
# Rows are built in code rather than laid out in settings.tscn so the list stays
|
||||||
|
# derived from InputSettings.ACTIONS — adding a rebindable action means editing
|
||||||
|
# that one const, not this scene as well.
|
||||||
|
|
||||||
|
# A joypad axis has to travel this far before a capture accepts it. Resting
|
||||||
|
# stick drift is routinely a few percent off centre and would otherwise bind
|
||||||
|
# itself the instant the player opened a capture.
|
||||||
|
const AXIS_CAPTURE_THRESHOLD := 0.5
|
||||||
|
|
||||||
|
@onready var keyboard_button: Button = %KeyboardButton
|
||||||
|
@onready var controller_button: Button = %ControllerButton
|
||||||
|
@onready var status_label: Label = %StatusLabel
|
||||||
|
@onready var binding_list: VBoxContainer = %BindingList
|
||||||
|
@onready var invert_pitch_check: CheckBox = %InvertPitchCheck
|
||||||
|
@onready var reset_button: Button = %ResetButton
|
||||||
|
|
||||||
|
var _device: String = InputSettings.DEVICE_KEYBOARD
|
||||||
|
# The action currently awaiting an input event, or "" when not capturing.
|
||||||
|
var _capturing: String = ""
|
||||||
|
# action -> the row's Button, so a rebuild-free label refresh is possible and
|
||||||
|
# so capture can restore the right button's text on cancel.
|
||||||
|
var _row_buttons: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
keyboard_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_KEYBOARD))
|
||||||
|
controller_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_JOYPAD))
|
||||||
|
invert_pitch_check.toggled.connect(_on_invert_pitch_toggled)
|
||||||
|
reset_button.pressed.connect(_on_reset_pressed)
|
||||||
|
invert_pitch_check.button_pressed = InputSettings.invert_pitch
|
||||||
|
_update_device_buttons()
|
||||||
|
_rebuild_rows()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_device_selected(device: String) -> void:
|
||||||
|
_cancel_capture()
|
||||||
|
_device = device
|
||||||
|
_update_device_buttons()
|
||||||
|
_rebuild_rows()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_device_buttons() -> void:
|
||||||
|
keyboard_button.button_pressed = _device == InputSettings.DEVICE_KEYBOARD
|
||||||
|
controller_button.button_pressed = _device == InputSettings.DEVICE_JOYPAD
|
||||||
|
|
||||||
|
|
||||||
|
func _rebuild_rows() -> void:
|
||||||
|
for child in binding_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
_row_buttons.clear()
|
||||||
|
|
||||||
|
var last_group := ""
|
||||||
|
for entry in InputSettings.ACTIONS:
|
||||||
|
var group: String = entry["group"]
|
||||||
|
if group != last_group:
|
||||||
|
last_group = group
|
||||||
|
binding_list.add_child(_make_group_header(group))
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 10)
|
||||||
|
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = entry["label"]
|
||||||
|
label.custom_minimum_size = Vector2(200, 0)
|
||||||
|
row.add_child(label)
|
||||||
|
|
||||||
|
var button := Button.new()
|
||||||
|
var action: String = entry["action"]
|
||||||
|
button.text = InputSettings.binding_text(action, _device)
|
||||||
|
button.custom_minimum_size = Vector2(0, 36)
|
||||||
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
button.clip_text = true
|
||||||
|
button.pressed.connect(_begin_capture.bind(action))
|
||||||
|
row.add_child(button)
|
||||||
|
_row_buttons[action] = button
|
||||||
|
|
||||||
|
binding_list.add_child(row)
|
||||||
|
|
||||||
|
# Re-bound after every rebuild: the rows above are new nodes each time, so
|
||||||
|
# the buttons AudioManager was tracking no longer exist.
|
||||||
|
AudioManager.bind_tree_buttons(self)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_group_header(group: String) -> Label:
|
||||||
|
var header := Label.new()
|
||||||
|
header.text = group
|
||||||
|
header.add_theme_font_size_override("font_size", 18)
|
||||||
|
header.modulate = Color(1, 1, 1, 0.7)
|
||||||
|
return header
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_capture(action: String) -> void:
|
||||||
|
_cancel_capture()
|
||||||
|
_capturing = action
|
||||||
|
var button: Button = _row_buttons[action]
|
||||||
|
button.text = "Press a key…" if _device == InputSettings.DEVICE_KEYBOARD else "Press a button…"
|
||||||
|
status_label.text = "Listening — press Escape to cancel."
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_capture() -> void:
|
||||||
|
if _capturing == "":
|
||||||
|
return
|
||||||
|
var action := _capturing
|
||||||
|
_capturing = ""
|
||||||
|
if _row_buttons.has(action) and is_instance_valid(_row_buttons[action]):
|
||||||
|
_row_buttons[action].text = InputSettings.binding_text(action, _device)
|
||||||
|
status_label.text = ""
|
||||||
|
|
||||||
|
|
||||||
|
# _input rather than _unhandled_input: the row Button has focus while capturing,
|
||||||
|
# and an unhandled-input handler would never see the key that Button consumes as
|
||||||
|
# its own activation. Everything consumed here is marked handled so the pending
|
||||||
|
# event cannot also re-press that button and re-enter capture.
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if _capturing == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
_cancel_capture()
|
||||||
|
return
|
||||||
|
|
||||||
|
var captured := _capturable_event(event)
|
||||||
|
if captured == null:
|
||||||
|
return
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
var action := _capturing
|
||||||
|
_capturing = ""
|
||||||
|
var displaced := InputSettings.set_binding(action, captured)
|
||||||
|
_rebuild_rows()
|
||||||
|
if displaced.is_empty():
|
||||||
|
status_label.text = ""
|
||||||
|
else:
|
||||||
|
status_label.text = "Unbound %s — it was using the same input." % ", ".join(_labels_for(displaced))
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the event to bind, or null if this event is not a legal binding for
|
||||||
|
# the device kind currently being edited. Keeping the check here means a joypad
|
||||||
|
# press can never land in the keyboard column just because that tab was open.
|
||||||
|
func _capturable_event(event: InputEvent) -> InputEvent:
|
||||||
|
if _device == InputSettings.DEVICE_KEYBOARD:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo:
|
||||||
|
var key := InputEventKey.new()
|
||||||
|
key.physical_keycode = event.physical_keycode
|
||||||
|
return key
|
||||||
|
return null
|
||||||
|
|
||||||
|
if event is InputEventJoypadButton and event.pressed:
|
||||||
|
var button := InputEventJoypadButton.new()
|
||||||
|
button.button_index = event.button_index
|
||||||
|
return button
|
||||||
|
if event is InputEventJoypadMotion and absf(event.axis_value) >= AXIS_CAPTURE_THRESHOLD:
|
||||||
|
var motion := InputEventJoypadMotion.new()
|
||||||
|
motion.axis = event.axis
|
||||||
|
motion.axis_value = signf(event.axis_value)
|
||||||
|
return motion
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _labels_for(actions: PackedStringArray) -> PackedStringArray:
|
||||||
|
var out := PackedStringArray()
|
||||||
|
for action in actions:
|
||||||
|
for entry in InputSettings.ACTIONS:
|
||||||
|
if entry["action"] == action:
|
||||||
|
out.append(entry["label"])
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _on_invert_pitch_toggled(pressed: bool) -> void:
|
||||||
|
InputSettings.invert_pitch = pressed
|
||||||
|
|
||||||
|
|
||||||
|
func _on_reset_pressed() -> void:
|
||||||
|
_cancel_capture()
|
||||||
|
InputSettings.reset_all()
|
||||||
|
invert_pitch_check.button_pressed = InputSettings.invert_pitch
|
||||||
|
_rebuild_rows()
|
||||||
|
status_label.text = "Bindings reset to defaults."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dk7plirjfqvld
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class_name EnetTransport
|
||||||
|
extends NetTransport
|
||||||
|
|
||||||
|
func transport_id() -> String:
|
||||||
|
return "enet"
|
||||||
|
|
||||||
|
|
||||||
|
func is_available() -> bool:
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func create_server(port: int, max_clients: int) -> Dictionary:
|
||||||
|
var peer := ENetMultiplayerPeer.new()
|
||||||
|
var err := peer.create_server(port, max_clients)
|
||||||
|
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||||
|
|
||||||
|
|
||||||
|
func create_client(address: String, port: int) -> Dictionary:
|
||||||
|
var peer := ENetMultiplayerPeer.new()
|
||||||
|
var err := peer.create_client(address, port)
|
||||||
|
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://505pjuvqynm0
|
||||||
+100
-70
@@ -17,16 +17,10 @@ var hud: HUDController
|
|||||||
var ball: RigidBody3D
|
var ball: RigidBody3D
|
||||||
var ships: Array[Ship] = []
|
var ships: Array[Ship] = []
|
||||||
var _ship_spawn_transforms := {}
|
var _ship_spawn_transforms := {}
|
||||||
var _hit_stop_generation := 0
|
|
||||||
var _hit_stop_active := false
|
|
||||||
var _time_scale_before_hit_stop := 1.0
|
|
||||||
var _camera_rig: ShipCameraRig
|
var _camera_rig: ShipCameraRig
|
||||||
var _goal_slowmo_active := false
|
|
||||||
var _time_scale_before_goal := 1.0
|
|
||||||
var _goal_in_progress := false
|
var _goal_in_progress := false
|
||||||
|
|
||||||
const GOAL_CELEBRATION_SECONDS := 1.6
|
const GOAL_CELEBRATION_SECONDS := 1.6
|
||||||
const GOAL_SLOWMO_SCALE := 0.22
|
|
||||||
|
|
||||||
# Shared by modes that keep score (Match, Spectate); Free Play never
|
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||||
# references this or emits a score_changed signal, and HUDController relies
|
# references this or emits a score_changed signal, and HUDController relies
|
||||||
@@ -38,6 +32,18 @@ var score := {0: 0, 1: 0}
|
|||||||
func _ready():
|
func _ready():
|
||||||
# Group lets the HUD discover the game mode for timer/score signals
|
# Group lets the HUD discover the game mode for timer/score signals
|
||||||
add_to_group("game")
|
add_to_group("game")
|
||||||
|
# At the default 8, a client hitching to ~20 fps runs up to 8 physics
|
||||||
|
# ticks in one rendered frame — and each of those ticks costs roughly as
|
||||||
|
# much as the frame that caused the hitch, so the client can spiral
|
||||||
|
# further behind instead of recovering. 4 trades a lower worst-case
|
||||||
|
# catch-up rate for bounded per-frame cost.
|
||||||
|
Engine.max_physics_steps_per_frame = 4
|
||||||
|
# A fresh RandomNumberGenerator defaults to a fixed internal state (unlike
|
||||||
|
# the global randf_range, which Godot auto-randomizes at startup), so an
|
||||||
|
# explicit randomize() is required unless a seed was set for reproducible
|
||||||
|
# kickoffs (see kickoff_rng_seed above).
|
||||||
|
if kickoff_rng_seed == 0:
|
||||||
|
_kickoff_rng.randomize()
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child is Arena:
|
if child is Arena:
|
||||||
arena = child
|
arena = child
|
||||||
@@ -51,6 +57,7 @@ func _ready():
|
|||||||
if not arena:
|
if not arena:
|
||||||
push_error("GameMode: scene has no Arena child")
|
push_error("GameMode: scene has no Arena child")
|
||||||
return
|
return
|
||||||
|
if _owns_goal_logic():
|
||||||
for goal in arena.get_goals():
|
for goal in arena.get_goals():
|
||||||
goal.goal_scored.connect(_handle_goal_scored)
|
goal.goal_scored.connect(_handle_goal_scored)
|
||||||
_start()
|
_start()
|
||||||
@@ -68,6 +75,31 @@ func _start() -> void:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual: whether this mode decides goals from its own local Goal sensors.
|
||||||
|
# True for every mode today. A future networked client mode overrides this
|
||||||
|
# false — it must learn a goal happened from an authoritative server message,
|
||||||
|
# not from an interpolated remote ball wandering through its local Goal
|
||||||
|
# Area3D, which would score client-side against no one.
|
||||||
|
func _owns_goal_logic() -> bool:
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual: whether this mode simulates and enforces its own world (escaped-
|
||||||
|
# body respawn runs locally in _physics_process). True for every mode today.
|
||||||
|
# A future networked client mode overrides this false — the server is
|
||||||
|
# authoritative for body positions, and a client respawning a body itself
|
||||||
|
# would fight that authority.
|
||||||
|
func _owns_world_simulation() -> bool:
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual: how long the goal cinematic holds before resuming play. Subclasses
|
||||||
|
# that want a different cadence override this instead of touching
|
||||||
|
# GOAL_CELEBRATION_SECONDS directly.
|
||||||
|
func _goal_pause_seconds() -> float:
|
||||||
|
return GOAL_CELEBRATION_SECONDS
|
||||||
|
|
||||||
|
|
||||||
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
|
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
|
||||||
func _on_goal_scored(_conceding_team: int) -> void:
|
func _on_goal_scored(_conceding_team: int) -> void:
|
||||||
pass
|
pass
|
||||||
@@ -88,7 +120,14 @@ func _handle_goal_scored(conceding_team: int) -> void:
|
|||||||
_goal_in_progress = true
|
_goal_in_progress = true
|
||||||
_on_goal_registered(conceding_team)
|
_on_goal_registered(conceding_team)
|
||||||
await _play_goal_celebration(1 - conceding_team, conceding_team)
|
await _play_goal_celebration(1 - conceding_team, conceding_team)
|
||||||
|
# A scene change (Esc, match end) queued during the celebration removes
|
||||||
|
# this node from the tree before the await chain finishes; resuming past
|
||||||
|
# that point would touch arena/hud state that is mid-teardown.
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
await _on_goal_scored(conceding_team)
|
await _on_goal_scored(conceding_team)
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
_goal_in_progress = false
|
_goal_in_progress = false
|
||||||
|
|
||||||
|
|
||||||
@@ -97,34 +136,24 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
|
|||||||
# real-time presentation delay between episodes.
|
# real-time presentation delay between episodes.
|
||||||
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
||||||
return
|
return
|
||||||
_restore_hit_stop()
|
AudioManager.play_goal()
|
||||||
_goal_slowmo_active = true
|
|
||||||
_time_scale_before_goal = Engine.time_scale
|
|
||||||
Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE)
|
|
||||||
var goal_position := Vector3.ZERO
|
var goal_position := Vector3.ZERO
|
||||||
for goal in arena.get_goals():
|
for goal in arena.get_goals():
|
||||||
if goal.team == conceding_team:
|
if goal.team == conceding_team:
|
||||||
goal_position = goal.global_position
|
goal_position = goal.global_position
|
||||||
break
|
break
|
||||||
|
# The cinematic camera cut itself (hard FOV change, cut to a fixed angle)
|
||||||
|
# carries the "moment" that Engine.time_scale slow-mo used to sell —
|
||||||
|
# world simulation speed is never touched, so this behaves identically
|
||||||
|
# for a future networked client watching a shared server sim.
|
||||||
_camera_rig.begin_goal_cut(goal_position)
|
_camera_rig.begin_goal_cut(goal_position)
|
||||||
if hud:
|
if hud:
|
||||||
hud.show_goal_celebration(scoring_team)
|
hud.show_goal_celebration(scoring_team)
|
||||||
await get_tree().create_timer(GOAL_CELEBRATION_SECONDS, true, false, true).timeout
|
await get_tree().create_timer(_goal_pause_seconds(), true, false, true).timeout
|
||||||
if is_instance_valid(_camera_rig):
|
if is_instance_valid(_camera_rig):
|
||||||
_camera_rig.end_goal_cut()
|
_camera_rig.end_goal_cut()
|
||||||
if hud and is_instance_valid(hud):
|
if hud and is_instance_valid(hud):
|
||||||
hud.hide_goal_celebration()
|
hud.hide_goal_celebration()
|
||||||
# Defensive unwind: impact feedback is suppressed while goal slow-mo owns
|
|
||||||
# time scale, but restore any hit-stop that was already queued this frame.
|
|
||||||
_restore_hit_stop()
|
|
||||||
_restore_goal_slowmo()
|
|
||||||
|
|
||||||
|
|
||||||
func _restore_goal_slowmo() -> void:
|
|
||||||
if not _goal_slowmo_active:
|
|
||||||
return
|
|
||||||
Engine.time_scale = _time_scale_before_goal
|
|
||||||
_goal_slowmo_active = false
|
|
||||||
|
|
||||||
|
|
||||||
func spawn_ball() -> RigidBody3D:
|
func spawn_ball() -> RigidBody3D:
|
||||||
@@ -136,7 +165,7 @@ func spawn_ball() -> RigidBody3D:
|
|||||||
|
|
||||||
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
||||||
var ship: Ship = ship_scene.instantiate()
|
var ship: Ship = ship_scene.instantiate()
|
||||||
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
|
ship.name = "Ship_T%d_S%d" % [team, spawn_index]
|
||||||
add_child(ship)
|
add_child(ship)
|
||||||
var spawns := arena.get_ship_spawns(team)
|
var spawns := arena.get_ship_spawns(team)
|
||||||
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
||||||
@@ -154,8 +183,8 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
|||||||
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
||||||
add_child(rig)
|
add_child(rig)
|
||||||
_camera_rig = rig
|
_camera_rig = rig
|
||||||
|
rig.impact_feedback.connect(AudioManager.play_impact)
|
||||||
rig.target = target
|
rig.target = target
|
||||||
rig.impact_feedback.connect(_on_player_impact)
|
|
||||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
# Also wires the scene's static HUD (if any) to the same ship, rather
|
||||||
# than letting it guess via the "ship" group.
|
# than letting it guess via the "ship" group.
|
||||||
if hud:
|
if hud:
|
||||||
@@ -163,42 +192,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
|||||||
return rig
|
return rig
|
||||||
|
|
||||||
|
|
||||||
func _on_player_impact(intensity: float) -> void:
|
|
||||||
if not _goal_slowmo_active:
|
|
||||||
_run_hit_stop(intensity)
|
|
||||||
|
|
||||||
|
|
||||||
func _run_hit_stop(intensity: float) -> void:
|
|
||||||
if _goal_slowmo_active:
|
|
||||||
return
|
|
||||||
_hit_stop_generation += 1
|
|
||||||
var generation := _hit_stop_generation
|
|
||||||
if not _hit_stop_active:
|
|
||||||
_time_scale_before_hit_stop = Engine.time_scale
|
|
||||||
_hit_stop_active = true
|
|
||||||
Engine.time_scale = minf(
|
|
||||||
Engine.time_scale, lerpf(0.22, 0.06, clampf(intensity, 0.0, 1.0))
|
|
||||||
)
|
|
||||||
await get_tree().create_timer(
|
|
||||||
lerpf(0.025, 0.065, clampf(intensity, 0.0, 1.0)), true, false, true
|
|
||||||
).timeout
|
|
||||||
if generation == _hit_stop_generation:
|
|
||||||
_restore_hit_stop()
|
|
||||||
|
|
||||||
|
|
||||||
func _restore_hit_stop() -> void:
|
|
||||||
if not _hit_stop_active:
|
|
||||||
return
|
|
||||||
Engine.time_scale = _time_scale_before_hit_stop
|
|
||||||
_hit_stop_active = false
|
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
|
||||||
# A scene change during the unscaled timer must never strand global time.
|
|
||||||
_restore_hit_stop()
|
|
||||||
_restore_goal_slowmo()
|
|
||||||
|
|
||||||
|
|
||||||
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
||||||
# apply their own GameSettings-override logic first, which differs between
|
# apply their own GameSettings-override logic first, which differs between
|
||||||
# modes (Match lets GameSettings override all three fields, Spectate only
|
# modes (Match lets GameSettings override all three fields, Spectate only
|
||||||
@@ -233,6 +226,16 @@ func _record_goal(scoring_team: int) -> void:
|
|||||||
const KICKOFF_POSITION_JITTER := 0.3
|
const KICKOFF_POSITION_JITTER := 0.3
|
||||||
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
|
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
|
||||||
|
|
||||||
|
# Owned rather than global `randf_range`, so a fixed seed makes kickoffs
|
||||||
|
# exactly reproducible (replay logs, deterministic tests) without disturbing
|
||||||
|
# any other system's random stream.
|
||||||
|
@export var kickoff_rng_seed: int = 0:
|
||||||
|
set(value):
|
||||||
|
kickoff_rng_seed = value
|
||||||
|
if value != 0:
|
||||||
|
_kickoff_rng.seed = value
|
||||||
|
var _kickoff_rng := RandomNumberGenerator.new()
|
||||||
|
|
||||||
|
|
||||||
func reset_ball() -> void:
|
func reset_ball() -> void:
|
||||||
if is_instance_valid(ball):
|
if is_instance_valid(ball):
|
||||||
@@ -243,28 +246,41 @@ func reset_ships() -> void:
|
|||||||
for ship in ships:
|
for ship in ships:
|
||||||
if is_instance_valid(ship):
|
if is_instance_valid(ship):
|
||||||
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
|
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
|
||||||
|
if is_instance_valid(_camera_rig):
|
||||||
|
# _reset_body's queue_teleport defers the actual transform write to
|
||||||
|
# the ship's next _integrate_forces (task 0.15) — snapping the camera
|
||||||
|
# now would read the pre-teleport position. Wait one physics tick so
|
||||||
|
# the teleport has already landed; without this the camera would also
|
||||||
|
# smoothly chase the teleported ship across the arena instead of
|
||||||
|
# cutting with it.
|
||||||
|
await get_tree().physics_frame
|
||||||
|
if is_instance_valid(_camera_rig):
|
||||||
|
_camera_rig.snap_to_target()
|
||||||
|
|
||||||
|
|
||||||
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
|
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
|
||||||
var offset := Vector3(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter))
|
var offset := Vector3(_kickoff_rng.randf_range(-position_jitter, position_jitter), 0.0, _kickoff_rng.randf_range(-position_jitter, position_jitter))
|
||||||
var basis := to.basis
|
var basis := to.basis
|
||||||
if yaw_jitter > 0.0:
|
if yaw_jitter > 0.0:
|
||||||
basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter))
|
basis = basis.rotated(Vector3.UP, _kickoff_rng.randf_range(-yaw_jitter, yaw_jitter))
|
||||||
return Transform3D(basis, to.origin + offset)
|
return Transform3D(basis, to.origin + offset)
|
||||||
|
|
||||||
|
|
||||||
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||||
# Deferred: a RigidBody3D transform can't be set mid-physics-step
|
# Queued and applied inside the body's own _integrate_forces — the only
|
||||||
body.set_deferred("global_transform", to)
|
# Jolt-safe place to write state.transform — instead of racing the
|
||||||
body.set_deferred("linear_velocity", Vector3.ZERO)
|
# physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and
|
||||||
body.set_deferred("angular_velocity", Vector3.ZERO)
|
# Ball both implement queue_teleport(), but RigidBody3D itself doesn't,
|
||||||
# A kickoff reset is a teleport: without this, physics interpolation
|
# so a statically-typed call here won't resolve.
|
||||||
# smears the body across the arena for a frame
|
body.call("queue_teleport", to)
|
||||||
body.call_deferred("reset_physics_interpolation")
|
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event):
|
func _unhandled_input(event):
|
||||||
if event.is_action_pressed("ui_cancel"):
|
# leave_gameplay (Escape / Start), NOT ui_cancel. ui_cancel carries the B
|
||||||
|
# button so menus behave the way a controller player expects, and B is far
|
||||||
|
# too easy to hit by accident for "abandon the match you are playing".
|
||||||
|
# Menus and the lobby still use ui_cancel; only live gameplay is guarded.
|
||||||
|
if event.is_action_pressed("leave_gameplay"):
|
||||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||||
|
|
||||||
|
|
||||||
@@ -282,17 +298,31 @@ const ESCAPE_MARGIN := 15.0
|
|||||||
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if _owns_world_simulation():
|
||||||
_respawn_escaped_bodies()
|
_respawn_escaped_bodies()
|
||||||
|
|
||||||
|
|
||||||
func _respawn_escaped_bodies() -> void:
|
func _respawn_escaped_bodies() -> void:
|
||||||
|
var respawned := false
|
||||||
for ship in ships:
|
for ship in ships:
|
||||||
if is_instance_valid(ship) and _is_escaped(ship.global_position):
|
if is_instance_valid(ship) and _is_escaped(ship.global_position):
|
||||||
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
|
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
|
||||||
_reset_body(ship, _ship_spawn_transforms[ship])
|
_reset_body(ship, _ship_spawn_transforms[ship])
|
||||||
|
respawned = true
|
||||||
if is_instance_valid(ball) and _is_escaped(ball.global_position):
|
if is_instance_valid(ball) and _is_escaped(ball.global_position):
|
||||||
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
|
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
|
||||||
_reset_body(ball, arena.get_ball_spawn())
|
_reset_body(ball, arena.get_ball_spawn())
|
||||||
|
respawned = true
|
||||||
|
if respawned:
|
||||||
|
_on_bodies_respawned()
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
|
||||||
|
# interpolating toward it would smoothly slide a body the width of the arena
|
||||||
|
# and then fight the correction. NetworkedMatch overrides this to bump
|
||||||
|
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
|
||||||
|
func _on_bodies_respawned() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
func _is_escaped(position: Vector3) -> bool:
|
func _is_escaped(position: Vector3) -> bool:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func _process(delta: float) -> void:
|
|||||||
_pitch = new_pitch
|
_pitch = new_pitch
|
||||||
_roll = new_roll
|
_roll = new_roll
|
||||||
if changed:
|
if changed:
|
||||||
queue_redraw()
|
_throttled_redraw(delta)
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func _process(delta: float) -> void:
|
|||||||
var changed := absf(new_value - _value) > max_value * 0.001
|
var changed := absf(new_value - _value) > max_value * 0.001
|
||||||
_value = new_value
|
_value = new_value
|
||||||
if changed:
|
if changed:
|
||||||
queue_redraw()
|
_throttled_redraw(delta)
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func _process(delta: float) -> void:
|
|||||||
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
|
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
|
||||||
_heading = new_heading
|
_heading = new_heading
|
||||||
if changed:
|
if changed:
|
||||||
queue_redraw()
|
_throttled_redraw(delta)
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ extends Control
|
|||||||
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
||||||
|
|
||||||
const SMOOTHING := 12.0
|
const SMOOTHING := 12.0
|
||||||
|
# _draw does real work (text shaping, building point arrays); nobody can
|
||||||
|
# perceive an instrument repainting faster than this, so redraws are paced
|
||||||
|
# to it independently of the render frame rate — value smoothing itself
|
||||||
|
# still runs every _process call, only the (expensive) repaint is throttled.
|
||||||
|
const REDRAW_INTERVAL := 1.0 / 60.0
|
||||||
|
|
||||||
|
var _time_since_redraw := 0.0
|
||||||
|
|
||||||
|
|
||||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||||
@@ -23,3 +30,12 @@ static func angle_delta_deg(from: float, to: float) -> float:
|
|||||||
|
|
||||||
func _smoothing_weight(delta: float) -> float:
|
func _smoothing_weight(delta: float) -> float:
|
||||||
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||||
|
|
||||||
|
|
||||||
|
# Call instead of queue_redraw() directly once a subclass's _process has
|
||||||
|
# decided the smoothed value moved enough to warrant a repaint.
|
||||||
|
func _throttled_redraw(delta: float) -> void:
|
||||||
|
_time_since_redraw += delta
|
||||||
|
if _time_since_redraw >= REDRAW_INTERVAL:
|
||||||
|
_time_since_redraw = 0.0
|
||||||
|
queue_redraw()
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
class_name InputJitterBuffer
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Per-player server-side input state (MULTIPLAYER_SPEC.md §3; multiplayer-next.md task 3.2).
|
||||||
|
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
|
||||||
|
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
|
||||||
|
# 3.5's unit tests can drive it with scripted arrival traces with no live
|
||||||
|
# match. NetworkedMatch owns one instance per connected slot and is the only
|
||||||
|
# thing that talks to the network layer; this class only knows about
|
||||||
|
# sequence numbers and ShipActions.
|
||||||
|
#
|
||||||
|
# Ring is fixed-size and slot-tagged (§3.1 step 5's "a client can never make
|
||||||
|
# the server allocate"): ingest() writes seq % RING_SIZE regardless of how
|
||||||
|
# large or malicious seq is, and consume() only ever trusts a slot whose
|
||||||
|
# stored seq exactly matches the one it expects — a stale or wrapped-around
|
||||||
|
# entry is indistinguishable from an empty one. Range/rate validation of seq
|
||||||
|
# against the current server tick is the CALLER's job (task 3.4), not this
|
||||||
|
# class's, since only the caller knows the current server tick.
|
||||||
|
|
||||||
|
const RING_SIZE := 32
|
||||||
|
# 500ms at 60Hz (MULTIPLAYER_SPEC.md §3.2's own numbers) — a duration, not a
|
||||||
|
# tick-rate-derived constant, so left as a literal rather than pulling in
|
||||||
|
# SimConstants for one number.
|
||||||
|
const STARVE_ZERO_TICKS := 30
|
||||||
|
|
||||||
|
var last_applied_seq := -1 # -1: consume() has never been called yet
|
||||||
|
var last_action := ShipAction.new()
|
||||||
|
var starved_ticks := 0
|
||||||
|
var stalled := false
|
||||||
|
|
||||||
|
var _ring_action: Array = []
|
||||||
|
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
||||||
|
# True once ingest() has ever been called for real. Consumption is a no-op
|
||||||
|
# (no starvation counted, no advancement) until then — see ingest()'s own
|
||||||
|
# comment for why an un-seeded buffer would otherwise never converge with
|
||||||
|
# what the client is actually sending.
|
||||||
|
var _seeded := false
|
||||||
|
# Highest seq ever seen by ingest(), regardless of whether it's still in the
|
||||||
|
# ring — consume()'s only way to tell "the data is gone because the ring
|
||||||
|
# overflowed" apart from "the data just hasn't arrived yet". See consume()'s
|
||||||
|
# own comment for why this exists: an adversarial review found that without
|
||||||
|
# it, a backlog bigger than RING_SIZE (a host stall, or persistent client/
|
||||||
|
# server clock drift) permanently zeroed a connected player's input for the
|
||||||
|
# rest of the match.
|
||||||
|
#
|
||||||
|
# Deliberately public (no underscore), same as last_applied_seq: the
|
||||||
|
# networked_match.gd caller's seq-range guard (§3.1 step 4) must bound
|
||||||
|
# against THIS, not against last_applied_seq. A second adversarial review
|
||||||
|
# found that bounding against last_applied_seq caps every accepted seq at
|
||||||
|
# last_applied_seq + RING_SIZE, which in turn caps this field at the same
|
||||||
|
# ceiling — making the resync condition below (which needs this field to
|
||||||
|
# reach expected + RING_SIZE) arithmetically unreachable on the only call
|
||||||
|
# path that exists in production. The two fixes looked independent but
|
||||||
|
# shared a variable and silently cancelled each other out. highest_ingested
|
||||||
|
# tracks the client's own send epoch instead, which the guard can safely
|
||||||
|
# let run ahead of a lagging consumer.
|
||||||
|
var highest_ingested_seq := -1
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_ring_action.resize(RING_SIZE)
|
||||||
|
_ring_seq.resize(RING_SIZE)
|
||||||
|
for i in RING_SIZE:
|
||||||
|
_ring_seq[i] = -1
|
||||||
|
|
||||||
|
|
||||||
|
# newest_seq/actions match NetCodec.unpack_input's own "seq"/"actions"
|
||||||
|
# fields directly: actions[i] is the action for sequence (newest_seq - i),
|
||||||
|
# newest-first. Already-consumed or stale entries are silently discarded
|
||||||
|
# (§3.1 step 5) — this is what makes redundant re-delivery of an already-
|
||||||
|
# applied tick harmless.
|
||||||
|
func ingest(newest_seq: int, actions: Array) -> void:
|
||||||
|
if not _seeded:
|
||||||
|
# The server starts calling consume() every tick the instant this
|
||||||
|
# slot exists — well before this player's first packet has had time
|
||||||
|
# to arrive (connection handshake, arena/ship spawn, first
|
||||||
|
# _physics_process tick on the client all take real time first). An
|
||||||
|
# un-seeded last_applied_seq of -1 would have consume() "expecting"
|
||||||
|
# sequence 0, 1, 2, ... via pure starvation the whole time, racing
|
||||||
|
# arbitrarily far ahead of whatever the client's own from-1
|
||||||
|
# numbering has actually reached by the time real packets show up —
|
||||||
|
# and since both sides only ever advance monotonically with no
|
||||||
|
# resync mechanism, that gap would never close. Seed to align
|
||||||
|
# "expected" with reality the moment real data first exists.
|
||||||
|
last_applied_seq = newest_seq - actions.size()
|
||||||
|
_seeded = true
|
||||||
|
if newest_seq > highest_ingested_seq:
|
||||||
|
highest_ingested_seq = newest_seq
|
||||||
|
for i in actions.size():
|
||||||
|
var seq: int = newest_seq - i
|
||||||
|
if seq <= last_applied_seq:
|
||||||
|
continue
|
||||||
|
var idx := seq % RING_SIZE
|
||||||
|
_ring_seq[idx] = seq
|
||||||
|
_ring_action[idx] = actions[i]
|
||||||
|
|
||||||
|
|
||||||
|
# Contiguous run of not-yet-applied entries starting right after
|
||||||
|
# last_applied_seq — reported as input_buffer_depth in every snapshot
|
||||||
|
# (§3.3) and consumed client-side by the input_lead control loop (task 3.3).
|
||||||
|
func depth() -> int:
|
||||||
|
if not _seeded or last_applied_seq < 0:
|
||||||
|
return 0
|
||||||
|
var d := 0
|
||||||
|
var seq := last_applied_seq + 1
|
||||||
|
while d < RING_SIZE and _ring_seq[seq % RING_SIZE] == seq:
|
||||||
|
d += 1
|
||||||
|
seq += 1
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# Called once per server physics tick, before the step (§3.2). A no-op
|
||||||
|
# (returns the zero-initialized last_action, no starvation counted) until
|
||||||
|
# this player's first real packet has ever arrived — see ingest()'s comment.
|
||||||
|
func consume() -> ShipAction:
|
||||||
|
if not _seeded:
|
||||||
|
return last_action
|
||||||
|
var expected := last_applied_seq + 1
|
||||||
|
var idx := expected % RING_SIZE
|
||||||
|
|
||||||
|
# Ring-overflow resync. A fixed-size ring can only ever hold RING_SIZE
|
||||||
|
# ticks of not-yet-consumed data at once — if the caller has fallen
|
||||||
|
# further behind the newest data actually arriving than that (a host
|
||||||
|
# stall, or persistent client/server clock drift), every tick between
|
||||||
|
# "expected" and "highest_ingested_seq - RING_SIZE" has already been
|
||||||
|
# irrecoverably overwritten by more recent arrivals landing on the same
|
||||||
|
# ring slots. Waiting for it tick-by-tick would starve — and, past
|
||||||
|
# STARVE_ZERO_TICKS, zero this player's ship — for the ENTIRE gap even
|
||||||
|
# though fresh, real input already exists in the ring right now. An
|
||||||
|
# adversarial review found and reproduced this exact failure (a ~0.7s
|
||||||
|
# host freeze permanently zeroed a connected player's input for the
|
||||||
|
# rest of the match, with no self-recovery). Skip the unrecoverable
|
||||||
|
# span and resync directly to what the ring can still actually provide.
|
||||||
|
if highest_ingested_seq - expected >= RING_SIZE:
|
||||||
|
last_applied_seq = highest_ingested_seq - RING_SIZE
|
||||||
|
expected = last_applied_seq + 1
|
||||||
|
idx = expected % RING_SIZE
|
||||||
|
|
||||||
|
if _ring_seq[idx] == expected:
|
||||||
|
last_action = _ring_action[idx]
|
||||||
|
starved_ticks = 0
|
||||||
|
stalled = false
|
||||||
|
last_applied_seq = expected
|
||||||
|
return last_action
|
||||||
|
|
||||||
|
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
|
||||||
|
# and the client already predicted with the real input either way,
|
||||||
|
# so repeating minimises expected divergence (§3.2). Only zero after
|
||||||
|
# a sustained stall, so a disconnecting player's ship doesn't fly
|
||||||
|
# into a wall at full throttle forever.
|
||||||
|
starved_ticks += 1
|
||||||
|
if starved_ticks > STARVE_ZERO_TICKS:
|
||||||
|
last_action = ShipAction.new()
|
||||||
|
stalled = true
|
||||||
|
|
||||||
|
# Only GIVE UP on `expected` when strictly newer data has actually
|
||||||
|
# arrived, which proves it was lost or reordered rather than merely late.
|
||||||
|
#
|
||||||
|
# Advancing unconditionally (what this did originally) is catastrophic
|
||||||
|
# rather than merely lossy, because ingest() discards anything
|
||||||
|
# `seq <= last_applied_seq`. One starve on a sequence the client has not
|
||||||
|
# even sent yet leaves the server permanently one ahead of arrivals:
|
||||||
|
# both sides then advance one per tick, the gap never closes, and every
|
||||||
|
# honest packet is discarded on arrival for the rest of the match. An
|
||||||
|
# adversarial review reproduced exactly that on a clean LAN — the client's
|
||||||
|
# own input_lead RELEASE (delta == 0, which deliberately issues no new
|
||||||
|
# sequence for one tick) is sufficient to trigger it, so it fired roughly
|
||||||
|
# every 6.5s of ordinary play, blacking out input for 30 ticks until the
|
||||||
|
# lead controller's debounce allowed a +3 attack to jump the client clear.
|
||||||
|
#
|
||||||
|
# Holding cannot deadlock: if the client genuinely goes silent,
|
||||||
|
# highest_ingested_seq stops moving, starved_ticks still climbs, and the
|
||||||
|
# STARVE_ZERO_TICKS zeroing plus `stalled` above still fire on schedule.
|
||||||
|
# If it falls far behind instead, the ring-overflow resync above still
|
||||||
|
# jumps the cursor forward. Both escape paths are unchanged.
|
||||||
|
if highest_ingested_seq > expected:
|
||||||
|
last_applied_seq = expected
|
||||||
|
return last_action
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cp818kexskb34
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
class_name InputLeadController
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Client-owned input_lead control loop (MULTIPLAYER_SPEC.md §3.3; multiplayer-next.md task 3.3).
|
||||||
|
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
|
||||||
|
# so it's directly unit-testable against scripted depth traces.
|
||||||
|
#
|
||||||
|
# §3.3's own rationale for why this is the CLIENT's job alone, not shared
|
||||||
|
# with any server-side adaptation: three control loops acting on one plant
|
||||||
|
# (buffer occupancy) with different time constants is a textbook
|
||||||
|
# oscillation, and on a jittery link it presents to the player as
|
||||||
|
# intermittent sticky controls that are nearly impossible to attribute.
|
||||||
|
# The server (InputJitterBuffer, §3.2) only ever reports input_buffer_depth
|
||||||
|
# — it does nothing adaptive with it.
|
||||||
|
#
|
||||||
|
# "Lead" is realized concretely as extra distance between this client's own
|
||||||
|
# outgoing sequence numbers and what the server has actually consumed:
|
||||||
|
# skipping a sequence number (jumping the client's own seq counter by more
|
||||||
|
# than 1 for one tick) buys the server one more tick of buffered depth
|
||||||
|
# before it would starve; duplicating one (not incrementing the seq counter
|
||||||
|
# for one tick — the same seq gets sent again) narrows that margin by one
|
||||||
|
# tick of latency. The server's own ring buffer doesn't need to know this
|
||||||
|
# happened: a skipped seq just means "the redundant copies of it never
|
||||||
|
# existed, it's an ordinary drop" (already handled), and a duplicated seq
|
||||||
|
# is a same-seq resend, already discarded harmlessly once consumed
|
||||||
|
# (InputJitterBuffer.ingest()'s "seq <= last_applied_seq" check).
|
||||||
|
#
|
||||||
|
# Fast attack, slow release — a symmetric ±1-per-N-ticks slew would take
|
||||||
|
# two full seconds to absorb a single wifi spike, during which the player
|
||||||
|
# steers and the ship does not turn, "the most rage-inducing failure mode
|
||||||
|
# in any netcode" per §3.3's own words.
|
||||||
|
|
||||||
|
const LEAD_MIN := 1
|
||||||
|
const LEAD_MAX := 12
|
||||||
|
# "Never change it more than once per 30 ticks" (§3.3) — the floor that
|
||||||
|
# binds the fast-attack side; slow-release's own 60-tick cadence already
|
||||||
|
# exceeds it, so this one constant covers both.
|
||||||
|
const MIN_CHANGE_INTERVAL_TICKS := 30
|
||||||
|
const RELEASE_INTERVAL_TICKS := 60
|
||||||
|
const CLEAN_SURPLUS_TICKS := 120 # 2s at 60Hz
|
||||||
|
# §3.3: "target_depth = 1 (16.7 ms), not 2." Release only fires when the
|
||||||
|
# server-reported depth is genuinely ABOVE this — see update()'s own
|
||||||
|
# comment for why gating on `lead` alone (an adversarial review's original
|
||||||
|
# finding here) was wrong.
|
||||||
|
const TARGET_DEPTH := 1
|
||||||
|
|
||||||
|
var lead := LEAD_MIN
|
||||||
|
|
||||||
|
var _ticks_since_change := 0
|
||||||
|
var _clean_surplus_ticks := 0
|
||||||
|
|
||||||
|
|
||||||
|
# Call once per client physics tick with the most recently known server-
|
||||||
|
# reported input_buffer_depth for THIS client's own slot (echoed in every
|
||||||
|
# snapshot, §3.2) — or -1 if no snapshot carrying that field has arrived
|
||||||
|
# yet. Returns the seq delta the caller should add for this tick's
|
||||||
|
# outgoing packet: ordinarily 1 (ship normally increments its send
|
||||||
|
# sequence by exactly one tick's worth), or 1+N / 0 on a tick where a lead
|
||||||
|
# change actually fires (skip N extra / duplicate the current one).
|
||||||
|
func update(input_buffer_depth: int, target_depth: int = TARGET_DEPTH) -> int:
|
||||||
|
_ticks_since_change += 1
|
||||||
|
if input_buffer_depth == -1:
|
||||||
|
return 1 # no server depth has arrived yet
|
||||||
|
if input_buffer_depth < -1:
|
||||||
|
# -1 is an explicit server starvation sentinel, distinct from a
|
||||||
|
# healthy zero-depth buffer on an adaptive clean link.
|
||||||
|
_clean_surplus_ticks = 0
|
||||||
|
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
||||||
|
var starve_lead := mini(lead + 3, LEAD_MAX)
|
||||||
|
var starve_delta := starve_lead - lead
|
||||||
|
lead = starve_lead
|
||||||
|
_ticks_since_change = 0
|
||||||
|
return 1 + starve_delta
|
||||||
|
return 1
|
||||||
|
target_depth = maxi(0, target_depth)
|
||||||
|
|
||||||
|
if input_buffer_depth <= target_depth - 1:
|
||||||
|
# A starve: the server's ring was empty for this player when it
|
||||||
|
# built that snapshot. React immediately, not after 2 seconds of
|
||||||
|
# evidence like release requires — but still debounced against
|
||||||
|
# MIN_CHANGE_INTERVAL_TICKS so a burst of consecutive starve
|
||||||
|
# reports doesn't compound into repeated, overlapping jumps.
|
||||||
|
_clean_surplus_ticks = 0
|
||||||
|
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
||||||
|
var new_lead := mini(lead + 3, LEAD_MAX)
|
||||||
|
var delta := new_lead - lead
|
||||||
|
lead = new_lead
|
||||||
|
_ticks_since_change = 0
|
||||||
|
return 1 + delta
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Release must react to the ACTUAL server-reported depth, not to this
|
||||||
|
# controller's own memory of past attacks. A first pass at this fix
|
||||||
|
# added the depth check above but left the OLD gate, `lead > LEAD_MIN`,
|
||||||
|
# still ANDed onto the final condition below — so a backlog this
|
||||||
|
# controller did NOT itself cause (a server hitch, persistent client/
|
||||||
|
# server clock drift, a ring resync) still could never be drained:
|
||||||
|
# with lead pinned at its starting floor, that clause always failed
|
||||||
|
# even while input_buffer_depth sat well above target. A second
|
||||||
|
# adversarial review caught it, confirmed by this file's own
|
||||||
|
# test_release_drains_a_backlog_it_never_caused_itself, whose original
|
||||||
|
# assertion text literally said "lead cannot release below its own
|
||||||
|
# floor even under large surplus" as if that were correct.
|
||||||
|
#
|
||||||
|
# The fix splits the one gate into two separate decisions: whether to
|
||||||
|
# duplicate this tick's seq (the only thing that actually narrows real
|
||||||
|
# buffered depth) follows the real signal alone, below; whether to
|
||||||
|
# keep decrementing `lead`'s own bookkeeping below its documented
|
||||||
|
# floor is a separate, cosmetic-only choice made inside that branch.
|
||||||
|
if input_buffer_depth > target_depth:
|
||||||
|
_clean_surplus_ticks += 1
|
||||||
|
else:
|
||||||
|
_clean_surplus_ticks = 0
|
||||||
|
|
||||||
|
if _clean_surplus_ticks >= CLEAN_SURPLUS_TICKS and _ticks_since_change >= RELEASE_INTERVAL_TICKS:
|
||||||
|
if lead > LEAD_MIN:
|
||||||
|
lead -= 1
|
||||||
|
_ticks_since_change = 0
|
||||||
|
return 0 # duplicate this tick's seq — one tick of latency recovered
|
||||||
|
return 1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bvwwwkf82nkdk
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Autoload: persisted keyboard/controller bindings on top of project.godot's
|
||||||
|
# [input] defaults, plus the flight-feel preferences that belong with them
|
||||||
|
# (invert pitch). The Settings screen's Controls tab (controls_settings.gd) is
|
||||||
|
# the only writer; PlayerShipController is the only reader of pitch_sign().
|
||||||
|
#
|
||||||
|
# project.godot stays the single source of truth for *defaults*: _ready()
|
||||||
|
# snapshots whatever InputMap holds at boot, before any override is applied, so
|
||||||
|
# the default table is never duplicated in GDScript and can never drift from the
|
||||||
|
# file. An override is only ever a delta on top of that snapshot.
|
||||||
|
#
|
||||||
|
# Persisted to user://input.cfg rather than user://settings.cfg, deliberately.
|
||||||
|
# VideoSettings.save() builds a fresh ConfigFile and writes it, which would drop
|
||||||
|
# every section it does not itself know about — so two autoloads sharing one
|
||||||
|
# file would silently erase each other. A separate file sidesteps that entirely
|
||||||
|
# instead of coupling the two save paths.
|
||||||
|
|
||||||
|
# Each action is bound at most once per device kind. That is a deliberate
|
||||||
|
# simplification of Godot's arbitrary-length event list: it makes a rebind row
|
||||||
|
# a single button rather than an editable list, and makes "what is X bound to?"
|
||||||
|
# answerable. The consequence is that applying a binding replaces the whole
|
||||||
|
# event list for that action (see apply()), so anything project.godot binds
|
||||||
|
# beyond one keyboard + one joypad event per action would be dropped here.
|
||||||
|
const DEVICE_KEYBOARD := "keyboard"
|
||||||
|
const DEVICE_JOYPAD := "joypad"
|
||||||
|
|
||||||
|
const SETTINGS_PATH := "user://input.cfg"
|
||||||
|
|
||||||
|
# The rebindable action list, and the only place the Controls tab and the tests
|
||||||
|
# read it from. Order is display order. Actions NOT listed here (ui_*, the F3/F4
|
||||||
|
# debug overlays) are deliberately not rebindable.
|
||||||
|
const ACTIONS := [
|
||||||
|
{"action": "move_forward", "label": "Thrust forward", "group": "Flight"},
|
||||||
|
{"action": "move_back", "label": "Thrust backward", "group": "Flight"},
|
||||||
|
{"action": "move_left", "label": "Strafe left", "group": "Flight"},
|
||||||
|
{"action": "move_right", "label": "Strafe right", "group": "Flight"},
|
||||||
|
{"action": "move_up", "label": "Thrust up", "group": "Flight"},
|
||||||
|
{"action": "move_down", "label": "Thrust down", "group": "Flight"},
|
||||||
|
{"action": "turbo", "label": "Turbo", "group": "Flight"},
|
||||||
|
{"action": "turn_left", "label": "Yaw left", "group": "Attitude"},
|
||||||
|
{"action": "turn_right", "label": "Yaw right", "group": "Attitude"},
|
||||||
|
{"action": "pitch_up", "label": "Pitch up", "group": "Attitude"},
|
||||||
|
{"action": "pitch_down", "label": "Pitch down", "group": "Attitude"},
|
||||||
|
{"action": "roll_left", "label": "Roll left", "group": "Attitude"},
|
||||||
|
{"action": "roll_right", "label": "Roll right", "group": "Attitude"},
|
||||||
|
{"action": "toggle_ball_cam", "label": "Ball camera", "group": "Other"},
|
||||||
|
{"action": "reset_ball", "label": "Reset ball (Free Play)", "group": "Other"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# button_index -> label, using the Xbox names the default map is expressed in.
|
||||||
|
# InputEvent.as_text() renders these as "Joypad Button 9 (Left Shoulder)", which
|
||||||
|
# is both long and wrong-looking in a rebind row.
|
||||||
|
const JOY_BUTTON_NAMES := {
|
||||||
|
JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y",
|
||||||
|
JOY_BUTTON_BACK: "Back", JOY_BUTTON_GUIDE: "Guide", JOY_BUTTON_START: "Start",
|
||||||
|
JOY_BUTTON_LEFT_STICK: "L3", JOY_BUTTON_RIGHT_STICK: "R3",
|
||||||
|
JOY_BUTTON_LEFT_SHOULDER: "LB", JOY_BUTTON_RIGHT_SHOULDER: "RB",
|
||||||
|
JOY_BUTTON_DPAD_UP: "D-Pad Up", JOY_BUTTON_DPAD_DOWN: "D-Pad Down",
|
||||||
|
JOY_BUTTON_DPAD_LEFT: "D-Pad Left", JOY_BUTTON_DPAD_RIGHT: "D-Pad Right",
|
||||||
|
}
|
||||||
|
|
||||||
|
# axis -> [label at negative deflection, label at positive deflection]. The
|
||||||
|
# triggers rest at 0 and only travel positive, so their negative half is never
|
||||||
|
# a reachable binding and is labelled as such rather than as a direction.
|
||||||
|
const JOY_AXIS_NAMES := {
|
||||||
|
JOY_AXIS_LEFT_X: ["Left Stick Left", "Left Stick Right"],
|
||||||
|
JOY_AXIS_LEFT_Y: ["Left Stick Up", "Left Stick Down"],
|
||||||
|
JOY_AXIS_RIGHT_X: ["Right Stick Left", "Right Stick Right"],
|
||||||
|
JOY_AXIS_RIGHT_Y: ["Right Stick Up", "Right Stick Down"],
|
||||||
|
JOY_AXIS_TRIGGER_LEFT: ["LT", "LT"],
|
||||||
|
JOY_AXIS_TRIGGER_RIGHT: ["RT", "RT"],
|
||||||
|
}
|
||||||
|
|
||||||
|
signal bindings_changed
|
||||||
|
|
||||||
|
# Push the right stick forward and the nose goes down (flight-sim). Ticking this
|
||||||
|
# flips it. Applied in PlayerShipController rather than by rewriting the
|
||||||
|
# bindings, so it stays one preference instead of two swapped rows the player
|
||||||
|
# then has to reason about.
|
||||||
|
var invert_pitch: bool = false
|
||||||
|
|
||||||
|
# action -> {DEVICE_KEYBOARD: InputEvent|null, DEVICE_JOYPAD: InputEvent|null},
|
||||||
|
# snapshotted from InputMap at boot before any override lands.
|
||||||
|
var _defaults: Dictionary = {}
|
||||||
|
# Same shape, but only for actions the player has actually customised. A device
|
||||||
|
# key that is absent means "still using the default"; a device key present with
|
||||||
|
# null means "the player deliberately unbound it".
|
||||||
|
var _overrides: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_capture_defaults()
|
||||||
|
_load()
|
||||||
|
apply()
|
||||||
|
|
||||||
|
|
||||||
|
# Reads project.godot's [input] back out of InputMap. Anything that is neither a
|
||||||
|
# key nor a joypad button/motion event (mouse buttons, say) is ignored rather
|
||||||
|
# than mis-filed under a device kind it does not belong to.
|
||||||
|
func _capture_defaults() -> void:
|
||||||
|
_defaults.clear()
|
||||||
|
for entry in ACTIONS:
|
||||||
|
var action: String = entry["action"]
|
||||||
|
var slots := {DEVICE_KEYBOARD: null, DEVICE_JOYPAD: null}
|
||||||
|
if InputMap.has_action(action):
|
||||||
|
for event in InputMap.action_get_events(action):
|
||||||
|
var kind := device_kind_of(event)
|
||||||
|
if kind != "" and slots[kind] == null:
|
||||||
|
slots[kind] = event
|
||||||
|
_defaults[action] = slots
|
||||||
|
|
||||||
|
|
||||||
|
# "" for an event this system cannot express (mouse, gesture, MIDI), which is
|
||||||
|
# also the signal to callers that it is not a legal binding.
|
||||||
|
static func device_kind_of(event: InputEvent) -> String:
|
||||||
|
if event is InputEventKey:
|
||||||
|
return DEVICE_KEYBOARD
|
||||||
|
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
|
||||||
|
return DEVICE_JOYPAD
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _load() -> void:
|
||||||
|
_overrides.clear()
|
||||||
|
var cfg := ConfigFile.new()
|
||||||
|
if cfg.load(SETTINGS_PATH) != OK:
|
||||||
|
return
|
||||||
|
invert_pitch = cfg.get_value("input", "invert_pitch", invert_pitch)
|
||||||
|
for entry in ACTIONS:
|
||||||
|
var action: String = entry["action"]
|
||||||
|
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
|
||||||
|
var key := "%s.%s" % [action, device]
|
||||||
|
if not cfg.has_section_key("bindings", key):
|
||||||
|
continue
|
||||||
|
var stored = cfg.get_value("bindings", key)
|
||||||
|
if not (stored is Dictionary):
|
||||||
|
continue
|
||||||
|
# An empty dict is the "deliberately unbound" sentinel — see save().
|
||||||
|
if stored.is_empty():
|
||||||
|
_set_override(action, device, null)
|
||||||
|
continue
|
||||||
|
var event := event_from_dict(stored)
|
||||||
|
if event != null:
|
||||||
|
_set_override(action, device, event)
|
||||||
|
|
||||||
|
|
||||||
|
func save() -> void:
|
||||||
|
var cfg := ConfigFile.new()
|
||||||
|
cfg.set_value("input", "invert_pitch", invert_pitch)
|
||||||
|
for action in _overrides:
|
||||||
|
var slots: Dictionary = _overrides[action]
|
||||||
|
for device in slots:
|
||||||
|
var event: InputEvent = slots[device]
|
||||||
|
var key := "%s.%s" % [action, device]
|
||||||
|
# An unbound override is written as an empty dict, NOT as null:
|
||||||
|
# ConfigFile.set_value() treats a null value as "erase this key", so
|
||||||
|
# storing null would drop the entry and the next load would fall
|
||||||
|
# back to the project default — silently rebinding something the
|
||||||
|
# player had deliberately cleared.
|
||||||
|
cfg.set_value("bindings", key, {} if event == null else event_to_dict(event))
|
||||||
|
cfg.save(SETTINGS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuilds InputMap for every rebindable action from defaults + overrides. Runs
|
||||||
|
# wholesale rather than incrementally so there is exactly one code path that
|
||||||
|
# decides what an action is bound to, whatever route got us here.
|
||||||
|
func apply() -> void:
|
||||||
|
for entry in ACTIONS:
|
||||||
|
var action: String = entry["action"]
|
||||||
|
if not InputMap.has_action(action):
|
||||||
|
continue
|
||||||
|
InputMap.action_erase_events(action)
|
||||||
|
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
|
||||||
|
var event := get_binding(action, device)
|
||||||
|
if event != null:
|
||||||
|
InputMap.action_add_event(action, event)
|
||||||
|
bindings_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func get_binding(action: String, device: String) -> InputEvent:
|
||||||
|
if _overrides.has(action) and _overrides[action].has(device):
|
||||||
|
return _overrides[action][device]
|
||||||
|
if _defaults.has(action):
|
||||||
|
return _defaults[action][device]
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func get_default_binding(action: String, device: String) -> InputEvent:
|
||||||
|
if not _defaults.has(action):
|
||||||
|
return null
|
||||||
|
return _defaults[action][device]
|
||||||
|
|
||||||
|
|
||||||
|
# Binds `event` to `action`, replacing whatever that action had for the event's
|
||||||
|
# own device kind. Returns the actions that were unbound to avoid a duplicate,
|
||||||
|
# so the caller can say so rather than leaving the player to discover it.
|
||||||
|
func set_binding(action: String, event: InputEvent) -> PackedStringArray:
|
||||||
|
var device := device_kind_of(event)
|
||||||
|
if device == "":
|
||||||
|
return PackedStringArray()
|
||||||
|
var displaced := find_conflicts(event, action)
|
||||||
|
for other in displaced:
|
||||||
|
_set_override(other, device, null)
|
||||||
|
_set_override(action, device, event)
|
||||||
|
apply()
|
||||||
|
return displaced
|
||||||
|
|
||||||
|
|
||||||
|
func clear_binding(action: String, device: String) -> void:
|
||||||
|
_set_override(action, device, null)
|
||||||
|
apply()
|
||||||
|
|
||||||
|
|
||||||
|
# Actions already bound to an equivalent event, excluding `except_action`.
|
||||||
|
# Compared by value rather than by object identity — the event coming out of a
|
||||||
|
# rebind capture is a different instance from the one in the map.
|
||||||
|
func find_conflicts(event: InputEvent, except_action: String = "") -> PackedStringArray:
|
||||||
|
var device := device_kind_of(event)
|
||||||
|
var out := PackedStringArray()
|
||||||
|
if device == "":
|
||||||
|
return out
|
||||||
|
for entry in ACTIONS:
|
||||||
|
var action: String = entry["action"]
|
||||||
|
if action == except_action:
|
||||||
|
continue
|
||||||
|
var bound := get_binding(action, device)
|
||||||
|
if bound != null and events_match(bound, event):
|
||||||
|
out.append(action)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Equality by the fields a binding is identified by. Deliberately not
|
||||||
|
# InputEvent.is_match(): for an axis that ignores axis_value, which would make
|
||||||
|
# "Right Stick Up" and "Right Stick Down" collide as the same binding.
|
||||||
|
static func events_match(a: InputEvent, b: InputEvent) -> bool:
|
||||||
|
if a is InputEventKey and b is InputEventKey:
|
||||||
|
return a.physical_keycode == b.physical_keycode
|
||||||
|
if a is InputEventJoypadButton and b is InputEventJoypadButton:
|
||||||
|
return a.button_index == b.button_index
|
||||||
|
if a is InputEventJoypadMotion and b is InputEventJoypadMotion:
|
||||||
|
return a.axis == b.axis and signf(a.axis_value) == signf(b.axis_value)
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func reset_action(action: String) -> void:
|
||||||
|
_overrides.erase(action)
|
||||||
|
apply()
|
||||||
|
|
||||||
|
|
||||||
|
func reset_all() -> void:
|
||||||
|
_overrides.clear()
|
||||||
|
invert_pitch = false
|
||||||
|
apply()
|
||||||
|
|
||||||
|
|
||||||
|
func has_override(action: String) -> bool:
|
||||||
|
return _overrides.has(action)
|
||||||
|
|
||||||
|
|
||||||
|
func pitch_sign() -> float:
|
||||||
|
return -1.0 if invert_pitch else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _set_override(action: String, device: String, event: InputEvent) -> void:
|
||||||
|
if not _overrides.has(action):
|
||||||
|
_overrides[action] = {}
|
||||||
|
_overrides[action][device] = event
|
||||||
|
|
||||||
|
|
||||||
|
# ConfigFile stores Dictionary values natively, so bindings persist as plain
|
||||||
|
# data. Never the Object(...) literal Godot writes into project.godot — that
|
||||||
|
# form is only parsed by the engine's own project-file loader, and round-tripping
|
||||||
|
# it through user:// would be storing engine-internal syntax in a save file.
|
||||||
|
static func event_to_dict(event: InputEvent) -> Dictionary:
|
||||||
|
if event is InputEventKey:
|
||||||
|
return {"type": "key", "physical_keycode": int(event.physical_keycode)}
|
||||||
|
if event is InputEventJoypadButton:
|
||||||
|
return {"type": "joy_button", "button_index": int(event.button_index)}
|
||||||
|
if event is InputEventJoypadMotion:
|
||||||
|
return {"type": "joy_axis", "axis": int(event.axis), "value": float(signf(event.axis_value))}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# Returns null for anything unrecognised, so a save file from a newer build (or
|
||||||
|
# a hand-edited one) degrades to "this action is unbound" rather than crashing
|
||||||
|
# the game before the player can reach the Controls tab to fix it.
|
||||||
|
static func event_from_dict(data: Dictionary) -> InputEvent:
|
||||||
|
match data.get("type", ""):
|
||||||
|
"key":
|
||||||
|
var key := InputEventKey.new()
|
||||||
|
key.physical_keycode = int(data.get("physical_keycode", 0))
|
||||||
|
return key if key.physical_keycode != 0 else null
|
||||||
|
"joy_button":
|
||||||
|
var button := InputEventJoypadButton.new()
|
||||||
|
button.button_index = int(data.get("button_index", -1))
|
||||||
|
return button if button.button_index >= 0 else null
|
||||||
|
"joy_axis":
|
||||||
|
var motion := InputEventJoypadMotion.new()
|
||||||
|
motion.axis = int(data.get("axis", -1))
|
||||||
|
motion.axis_value = signf(float(data.get("value", 0.0)))
|
||||||
|
return motion if motion.axis >= 0 and motion.axis_value != 0.0 else null
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func event_to_text(event: InputEvent) -> String:
|
||||||
|
if event == null:
|
||||||
|
return "Unbound"
|
||||||
|
if event is InputEventKey:
|
||||||
|
# Physical keycodes throughout, so the label matches the key's position
|
||||||
|
# on a non-QWERTY layout the same way the binding itself does. The
|
||||||
|
# headless display server has no keyboard layout to consult and pushes
|
||||||
|
# an ERROR for the attempt — which the ENet smoke gate treats as a
|
||||||
|
# failure on sight — so fall back to the unmapped keycode there.
|
||||||
|
var keycode: int = event.physical_keycode
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
|
keycode = DisplayServer.keyboard_get_keycode_from_physical(keycode)
|
||||||
|
return OS.get_keycode_string(keycode)
|
||||||
|
if event is InputEventJoypadButton:
|
||||||
|
return JOY_BUTTON_NAMES.get(event.button_index, "Button %d" % event.button_index)
|
||||||
|
if event is InputEventJoypadMotion:
|
||||||
|
if JOY_AXIS_NAMES.has(event.axis):
|
||||||
|
return JOY_AXIS_NAMES[event.axis][0 if event.axis_value < 0.0 else 1]
|
||||||
|
return "Axis %d%s" % [event.axis, "-" if event.axis_value < 0.0 else "+"]
|
||||||
|
return event.as_text()
|
||||||
|
|
||||||
|
|
||||||
|
func binding_text(action: String, device: String) -> String:
|
||||||
|
return event_to_text(get_binding(action, device))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bjtdsbem7kwdv
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
# Lobby (task 1.5): roster list split by team, team swap, ready toggle,
|
||||||
|
# leave. Reads/writes MatchNet.roster — this scene owns no state of its
|
||||||
|
# own, it's a view over the autoload. Reached via main_menu.gd's Host/Join
|
||||||
|
# flow (task 1.7) calling change_scene_to_file("res://scenes/lobby.tscn")
|
||||||
|
# after NetworkManager.host()/join() succeeds — this scene must always be
|
||||||
|
# loaded that way (as the real current_scene), not instantiated as a child
|
||||||
|
# of something else: change_scene_to_file() operates on
|
||||||
|
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
|
||||||
|
# below call it themselves, which hangs if this scene isn't actually the
|
||||||
|
# tree's current_scene when that happens (see multiplayer-next.md §9
|
||||||
|
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
|
||||||
|
|
||||||
|
@onready var _status_label: Label = %StatusLabel
|
||||||
|
@onready var _team0_list: VBoxContainer = %Team0List
|
||||||
|
@onready var _team1_list: VBoxContainer = %Team1List
|
||||||
|
@onready var _controls_row: HBoxContainer = %ControlsRow
|
||||||
|
@onready var _switch_team_button: Button = %SwitchTeamButton
|
||||||
|
@onready var _ready_button: CheckButton = %ReadyButton
|
||||||
|
@onready var _leave_button: Button = %LeaveButton
|
||||||
|
var _planned_server_shutdown := false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
MatchNet.welcomed.connect(_on_welcomed)
|
||||||
|
MatchNet.player_joined.connect(_on_roster_changed)
|
||||||
|
MatchNet.player_left.connect(_on_roster_changed)
|
||||||
|
MatchNet.player_state_changed.connect(_on_roster_changed)
|
||||||
|
MatchNet.rejected.connect(_on_rejected)
|
||||||
|
MatchNet.server_shutdown.connect(_on_server_shutdown)
|
||||||
|
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||||
|
|
||||||
|
# The server process is never a roster member (§1.1 decision 2) — it
|
||||||
|
# gets a read-only view, no team/ready controls to operate on itself.
|
||||||
|
_controls_row.visible = NetworkManager.is_client
|
||||||
|
|
||||||
|
_refresh()
|
||||||
|
if not MatchNet.last_server_shutdown_reason.is_empty():
|
||||||
|
_planned_server_shutdown = true
|
||||||
|
_status_label.text = "Server closed: %s" % MatchNet.last_server_shutdown_reason
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
NetworkManager.poll()
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
NetworkManager.poll()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
_leave()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_roster_changed(_a = null, _b = null, _c = null) -> void:
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_welcomed() -> void:
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rejected(reason: String) -> void:
|
||||||
|
_status_label.text = "Connection rejected: %s" % reason
|
||||||
|
|
||||||
|
|
||||||
|
func _on_server_shutdown(reason: String) -> void:
|
||||||
|
_planned_server_shutdown = true
|
||||||
|
_status_label.text = "Server closed: %s" % reason
|
||||||
|
|
||||||
|
|
||||||
|
func _on_disconnected_from_server() -> void:
|
||||||
|
if _planned_server_shutdown:
|
||||||
|
return
|
||||||
|
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_switch_team_pressed() -> void:
|
||||||
|
var my_id := multiplayer.get_unique_id()
|
||||||
|
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
|
||||||
|
if info == null:
|
||||||
|
return
|
||||||
|
MatchNet.request_set_team((info.team + 1) % MatchNet.TEAM_COUNT)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_ready_toggled(pressed: bool) -> void:
|
||||||
|
MatchNet.request_set_ready(pressed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_leave_pressed() -> void:
|
||||||
|
_leave()
|
||||||
|
|
||||||
|
|
||||||
|
func _leave() -> void:
|
||||||
|
NetworkManager.shutdown()
|
||||||
|
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh() -> void:
|
||||||
|
if NetworkManager.is_server:
|
||||||
|
_status_label.text = "Hosting — %d player(s) connected" % MatchNet.roster.size()
|
||||||
|
elif NetworkManager.is_client:
|
||||||
|
_status_label.text = "Connected" if not MatchNet.roster.is_empty() else "Connecting..."
|
||||||
|
else:
|
||||||
|
_status_label.text = "Not connected"
|
||||||
|
|
||||||
|
for child in _team0_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
for child in _team1_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
var my_id := multiplayer.get_unique_id()
|
||||||
|
var infos: Array = MatchNet.roster.values()
|
||||||
|
infos.sort_custom(func(a: MatchNet.PlayerInfo, b: MatchNet.PlayerInfo) -> bool: return a.peer_id < b.peer_id)
|
||||||
|
for info: MatchNet.PlayerInfo in infos:
|
||||||
|
var row := Label.new()
|
||||||
|
var marker := " (you)" if info.peer_id == my_id else ""
|
||||||
|
var ready_mark := "✓" if info.ready else "…"
|
||||||
|
row.text = "%s %s%s" % [ready_mark, info.player_name, marker]
|
||||||
|
var target_list := _team0_list if info.team == 0 else _team1_list
|
||||||
|
target_list.add_child(row)
|
||||||
|
|
||||||
|
if NetworkManager.is_client:
|
||||||
|
var my_info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
|
||||||
|
if my_info != null:
|
||||||
|
_ready_button.set_pressed_no_signal(my_info.ready)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://qd513s2cqls3
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
class_name LocalInputTimeline
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Client-only sequence/action timeline. It mirrors the stream the server's
|
||||||
|
# InputJitterBuffer will consume: an attack fills its deliberate sequence gap
|
||||||
|
# with repeat-last actions, while a release retransmits immutable data.
|
||||||
|
|
||||||
|
const ShipActionScript = preload("res://scripts/ship_action.gd")
|
||||||
|
const RETAINED_REDUNDANCY := 4
|
||||||
|
|
||||||
|
var latest_issued_seq := 0
|
||||||
|
var latest_applied_seq := -1
|
||||||
|
var configured := false
|
||||||
|
var _actions := {}
|
||||||
|
var _last_issued_action = ShipActionScript.new()
|
||||||
|
var _last_applied_action = ShipActionScript.new()
|
||||||
|
|
||||||
|
|
||||||
|
func configure_initial_delay(delay_ticks: int) -> void:
|
||||||
|
if configured:
|
||||||
|
return
|
||||||
|
latest_applied_seq = -maxi(delay_ticks, 1)
|
||||||
|
configured = true
|
||||||
|
|
||||||
|
|
||||||
|
func issue(delta: int, intent) -> int:
|
||||||
|
if delta <= 0:
|
||||||
|
# An already-issued sequence may be in flight or consumed. Never mutate
|
||||||
|
# it; carry current raw intent to the next unique command instead.
|
||||||
|
return latest_issued_seq
|
||||||
|
var from_seq := latest_issued_seq + 1
|
||||||
|
latest_issued_seq += delta
|
||||||
|
for seq in range(from_seq, latest_issued_seq):
|
||||||
|
_actions[seq] = _last_issued_action.copy()
|
||||||
|
_actions[latest_issued_seq] = intent.copy()
|
||||||
|
_last_issued_action = intent.copy()
|
||||||
|
_prune_consumed_actions()
|
||||||
|
return latest_issued_seq
|
||||||
|
|
||||||
|
|
||||||
|
func consume() -> Dictionary:
|
||||||
|
latest_applied_seq += 1
|
||||||
|
if _actions.has(latest_applied_seq):
|
||||||
|
_last_applied_action = _actions[latest_applied_seq].copy()
|
||||||
|
_prune_consumed_actions()
|
||||||
|
return {"seq": latest_applied_seq, "action": _last_applied_action.copy()}
|
||||||
|
|
||||||
|
|
||||||
|
# The action actually issued for a sequence, or null if it is no longer
|
||||||
|
# retained. Returns a copy: the timeline's stored actions are immutable once
|
||||||
|
# issued (see issue()), and handing out the live object would let a caller
|
||||||
|
# break that from the outside.
|
||||||
|
func action_for(seq: int):
|
||||||
|
if not _actions.has(seq):
|
||||||
|
return null
|
||||||
|
return _actions[seq].copy()
|
||||||
|
|
||||||
|
|
||||||
|
func packet_actions(max_count: int) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for seq in range(latest_issued_seq, maxi(0, latest_issued_seq - max_count), -1):
|
||||||
|
if not _actions.has(seq):
|
||||||
|
break
|
||||||
|
out.append(_actions[seq].copy())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func retained_action_count() -> int:
|
||||||
|
return _actions.size()
|
||||||
|
|
||||||
|
|
||||||
|
func _prune_consumed_actions() -> void:
|
||||||
|
# Preserve the local command needed for the server's redundancy window,
|
||||||
|
# then discard actions that are older than both consumption and backup use.
|
||||||
|
var keep_from := latest_issued_seq - RETAINED_REDUNDANCY + 1
|
||||||
|
for seq in _actions.keys():
|
||||||
|
if int(seq) < keep_from:
|
||||||
|
_actions.erase(seq)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b8nwh3anyddm5
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
class_name LocalNetShipController
|
||||||
|
extends ShipController
|
||||||
|
|
||||||
|
const LocalInputTimeline = preload("res://scripts/local_input_timeline.gd")
|
||||||
|
|
||||||
|
var source: ShipController
|
||||||
|
var timeline: LocalInputTimeline
|
||||||
|
var last_applied_seq := -1
|
||||||
|
var last_sampled_intent: ShipAction
|
||||||
|
|
||||||
|
|
||||||
|
func _init(new_source: ShipController, new_timeline: LocalInputTimeline) -> void:
|
||||||
|
source = new_source
|
||||||
|
timeline = new_timeline
|
||||||
|
last_sampled_intent = ShipAction.new()
|
||||||
|
|
||||||
|
|
||||||
|
func get_action() -> ShipAction:
|
||||||
|
# Ship invokes this exactly once per local physics tick. Prediction must use
|
||||||
|
# the player's current intent immediately; the timeline is transmission and
|
||||||
|
# immutable-redundancy bookkeeping only. Advance its cursor solely to label
|
||||||
|
# this post-step state at the estimated server-consumption sequence; never
|
||||||
|
# use its queued action to delay local control.
|
||||||
|
last_sampled_intent = source.get_action().copy()
|
||||||
|
var label := timeline.consume()
|
||||||
|
last_applied_seq = int(label["seq"])
|
||||||
|
return last_sampled_intent.copy()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dyaoxrjb006a8
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
class_name LocalPredictionHistory
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||||
|
|
||||||
|
# Client-owned local-ship prediction history (MULTIPLAYER_SPEC.md §4.3).
|
||||||
|
# This is deliberately independent of NetworkedMatch and the scene tree so
|
||||||
|
# sequence/ring behaviour can be tested from scripted traces. Each entry is
|
||||||
|
# tagged with its full sequence number: an old value in a wrapped slot is
|
||||||
|
# never accepted as a prediction for a newer sequence.
|
||||||
|
#
|
||||||
|
# Acknowledge and record are separate producer/consumer clocks. The input
|
||||||
|
# sender can continue producing while snapshots stop arriving, so record()
|
||||||
|
# explicitly marks overflow once more than RING_SIZE unacknowledged sequence
|
||||||
|
# positions exist. It still retains the newest representable window, but
|
||||||
|
# callers can see that an authoritative resync is required instead of
|
||||||
|
# mistaking a wrapped overwrite for a valid comparison.
|
||||||
|
#
|
||||||
|
# resync_required is a live condition, NOT a latch: compare_authoritative()
|
||||||
|
# clears it again once acknowledgements have genuinely caught back up (see
|
||||||
|
# that method). This mirrors input_jitter_buffer.gd's `stalled`, which
|
||||||
|
# likewise drops back to false the moment a normal tick is consumed again.
|
||||||
|
# A latched flag would mean one transient ~2s stall anywhere in a match
|
||||||
|
# permanently pinned every later tick into "needs a hard resync", which is
|
||||||
|
# exactly the behaviour soft correction exists to avoid — and it would also
|
||||||
|
# cap overflow_count at 1 forever, since a second episode could never
|
||||||
|
# observe the flag going false again.
|
||||||
|
#
|
||||||
|
# Two things a "matched" result does NOT guarantee, flagged for whoever
|
||||||
|
# builds task 4.3's actual correction logic on top of this:
|
||||||
|
#
|
||||||
|
# 1. A "matched" result can still be reporting stale data. The slot-tag
|
||||||
|
# equality check in get_prediction() guarantees a match's payload
|
||||||
|
# genuinely belongs to the queried seq (never wrong-seq data mislabeled
|
||||||
|
# as right), but nothing in the "matched" status itself says HOW OLD
|
||||||
|
# that entry is. Under sparse recording (record() is not called with
|
||||||
|
# strictly consecutive seqs — see the record() comment below), an entry
|
||||||
|
# from well over RING_SIZE ticks ago can still report "matched" for a
|
||||||
|
# query landing on its untouched residue. resync_required correctly
|
||||||
|
# stays true in that case (the span guard below is exact), but the
|
||||||
|
# comparison payload itself carries no matched_stale/age distinction. A
|
||||||
|
# caller wanting to reject "matched but ancient" needs to separately
|
||||||
|
# check newest_recorded_seq - seq itself.
|
||||||
|
#
|
||||||
|
# 2. HISTORICAL, now fixed — kept because the reasoning still constrains
|
||||||
|
# callers. record() used to be called twice for the same seq with a
|
||||||
|
# DIFFERENT action on the release path (delta == 0), the later call
|
||||||
|
# silently overwriting the slot. That was wrong, not merely imprecise:
|
||||||
|
# LocalInputTimeline.issue() deliberately does NOT mutate _actions[seq]
|
||||||
|
# for an already-issued sequence ("may be in flight or consumed"), so
|
||||||
|
# the overwrite made this ring contradict the wire — it claimed an
|
||||||
|
# action for S that was never sent for S. networked_match.gd now skips
|
||||||
|
# recording entirely on a release tick, leaving the original (correct)
|
||||||
|
# predicted[S] in place. Callers must keep it that way: an already-
|
||||||
|
# recorded sequence's ACTION is immutable here, exactly as it is in the
|
||||||
|
# timeline. Only overwrite_state()/rebase_state_range() may revise an
|
||||||
|
# entry, and only its state.
|
||||||
|
#
|
||||||
|
# 3. A sequence can be ISSUED without ever being locally SIMULATED. The
|
||||||
|
# input_lead controller's attack path (delta > 1) skips sequence numbers
|
||||||
|
# to buy server-side buffer margin: those gap sequences are filled with
|
||||||
|
# repeat-last actions and sent, but the client took exactly ONE physics
|
||||||
|
# step that tick, so no post-step state exists for them. They are
|
||||||
|
# recorded via record_unsimulated() and report "unsimulated_gap" rather
|
||||||
|
# than "missing_not_recorded" — a routine consequence of this client's
|
||||||
|
# own lead control, NOT evidence of history loss, and specifically not a
|
||||||
|
# hard-snap condition. Distinguishing them matters: treating them as
|
||||||
|
# missing history teleported the ship and armed resync suppression
|
||||||
|
# several times a minute during ordinary play.
|
||||||
|
|
||||||
|
const RING_SIZE := 128
|
||||||
|
|
||||||
|
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
||||||
|
var _ring_entry: Array = []
|
||||||
|
var _has_recorded := false
|
||||||
|
var _first_recorded_seq := -1
|
||||||
|
|
||||||
|
var newest_recorded_seq := -1
|
||||||
|
var last_acknowledged_seq := 0
|
||||||
|
var overflow_count := 0
|
||||||
|
var resync_required := false
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_ring_seq.resize(RING_SIZE)
|
||||||
|
_ring_entry.resize(RING_SIZE)
|
||||||
|
for i in RING_SIZE:
|
||||||
|
_ring_seq[i] = -1
|
||||||
|
|
||||||
|
|
||||||
|
# A reset starts a new authoritative epoch. Retained inputs/states describe
|
||||||
|
# the old world and must never be compared to the new kickoff state.
|
||||||
|
func begin_epoch() -> void:
|
||||||
|
for i in RING_SIZE:
|
||||||
|
_ring_seq[i] = -1
|
||||||
|
_ring_entry[i] = null
|
||||||
|
_has_recorded = false
|
||||||
|
_first_recorded_seq = -1
|
||||||
|
newest_recorded_seq = -1
|
||||||
|
last_acknowledged_seq = 0
|
||||||
|
resync_required = false
|
||||||
|
|
||||||
|
|
||||||
|
# Stores a private copy of both action and state. Returns true when this
|
||||||
|
# record crossed the unacknowledged-capacity boundary; the caller does not
|
||||||
|
# need that return today, but it makes the eviction event observable rather
|
||||||
|
# than silent when reconciliation starts applying corrections in Phase 4.3.
|
||||||
|
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
|
||||||
|
var overflowed_now := false
|
||||||
|
if not _has_recorded or seq > newest_recorded_seq:
|
||||||
|
if not _has_recorded:
|
||||||
|
_first_recorded_seq = seq
|
||||||
|
if seq - last_acknowledged_seq > RING_SIZE:
|
||||||
|
# Only the LEADING edge of an episode counts: resync_required is
|
||||||
|
# still true for every subsequent tick of the same stall, and
|
||||||
|
# counting those would report one outage as hundreds. Because
|
||||||
|
# compare_authoritative() can now clear the flag, a genuinely
|
||||||
|
# separate later episode does increment this again.
|
||||||
|
overflowed_now = not resync_required
|
||||||
|
resync_required = true
|
||||||
|
if overflowed_now:
|
||||||
|
overflow_count += 1
|
||||||
|
newest_recorded_seq = seq
|
||||||
|
_has_recorded = true
|
||||||
|
var idx := posmod(seq, RING_SIZE)
|
||||||
|
_ring_seq[idx] = seq
|
||||||
|
_ring_entry[idx] = {
|
||||||
|
"action": action.copy(),
|
||||||
|
"state": state.copy(),
|
||||||
|
"contact_window": contact_window,
|
||||||
|
"unsimulated": false,
|
||||||
|
}
|
||||||
|
return overflowed_now
|
||||||
|
|
||||||
|
|
||||||
|
# Records a sequence that was issued and sent but never locally simulated —
|
||||||
|
# an attack's skipped sequence numbers (see note 3 in this file's header).
|
||||||
|
# It advances the same newest/overflow bookkeeping record() does, because the
|
||||||
|
# sequence genuinely is outstanding and the server will genuinely acknowledge
|
||||||
|
# it; only the post-step state is absent, because the client never computed
|
||||||
|
# one. Deliberately carries the action anyway: it is what went on the wire, so
|
||||||
|
# a caller diagnosing an acknowledgement still has the honest command, and
|
||||||
|
# nothing here has to invent a state to keep the ring dense.
|
||||||
|
func record_unsimulated(seq: int, action: ShipAction) -> bool:
|
||||||
|
var overflowed_now := false
|
||||||
|
if not _has_recorded or seq > newest_recorded_seq:
|
||||||
|
if not _has_recorded:
|
||||||
|
_first_recorded_seq = seq
|
||||||
|
if seq - last_acknowledged_seq > RING_SIZE:
|
||||||
|
overflowed_now = not resync_required
|
||||||
|
resync_required = true
|
||||||
|
if overflowed_now:
|
||||||
|
overflow_count += 1
|
||||||
|
newest_recorded_seq = seq
|
||||||
|
_has_recorded = true
|
||||||
|
var idx := posmod(seq, RING_SIZE)
|
||||||
|
_ring_seq[idx] = seq
|
||||||
|
_ring_entry[idx] = {
|
||||||
|
"action": action.copy(),
|
||||||
|
"state": null,
|
||||||
|
"contact_window": false,
|
||||||
|
"unsimulated": true,
|
||||||
|
}
|
||||||
|
return overflowed_now
|
||||||
|
|
||||||
|
|
||||||
|
# Returns independent copies so diagnostic/reconciliation consumers cannot
|
||||||
|
# mutate a retained prediction by accident.
|
||||||
|
func get_prediction(seq: int) -> Dictionary:
|
||||||
|
var idx := posmod(seq, RING_SIZE)
|
||||||
|
if _ring_seq[idx] != seq:
|
||||||
|
return {}
|
||||||
|
var entry: Dictionary = _ring_entry[idx]
|
||||||
|
if bool(entry.get("unsimulated", false)):
|
||||||
|
# No state to hand back — see note 3. Callers must check this flag
|
||||||
|
# before touching "state"; it is null, not a zeroed NetBodyState,
|
||||||
|
# specifically so a caller that forgets fails loudly instead of
|
||||||
|
# silently comparing against the origin.
|
||||||
|
return {
|
||||||
|
"seq": seq,
|
||||||
|
"action": (entry["action"] as ShipAction).copy(),
|
||||||
|
"state": null,
|
||||||
|
"contact_window": false,
|
||||||
|
"unsimulated": true,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"seq": seq,
|
||||||
|
"action": (entry["action"] as ShipAction).copy(),
|
||||||
|
"state": (entry["state"] as NetBodyState).copy(),
|
||||||
|
"contact_window": bool(entry.get("contact_window", false)),
|
||||||
|
"unsimulated": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Reconciliation changes the state paired with already-sent input, never the
|
||||||
|
# input itself. This is deliberately a no-op for an absent/skipped sequence:
|
||||||
|
# input-lead control permits sparse sequence numbers, so there is no honest
|
||||||
|
# action to invent for such a slot.
|
||||||
|
func overwrite_state(seq: int, state: NetBodyState) -> bool:
|
||||||
|
var idx := posmod(seq, RING_SIZE)
|
||||||
|
if _ring_seq[idx] != seq:
|
||||||
|
return false
|
||||||
|
var entry: Dictionary = _ring_entry[idx]
|
||||||
|
if bool(entry.get("unsimulated", false)):
|
||||||
|
# Writing a state here would manufacture a local prediction for a
|
||||||
|
# sequence this client never simulated, which is exactly the fabricated
|
||||||
|
# history §4.4 forbids. The slot stays stateless.
|
||||||
|
return false
|
||||||
|
entry["state"] = state.copy()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func overwrite_state_range(from_seq: int, to_seq: int, state: NetBodyState) -> void:
|
||||||
|
for seq in range(from_seq, to_seq + 1):
|
||||||
|
overwrite_state(seq, state)
|
||||||
|
|
||||||
|
|
||||||
|
# Carries an authoritative same-sequence correction through the retained
|
||||||
|
# future. This is intentionally a transport operation, not a synthetic
|
||||||
|
# physics replay: the live Jolt body has already advanced through the real
|
||||||
|
# contact world, and a soft correction must not leave its later comparisons
|
||||||
|
# describing the old trajectory.
|
||||||
|
func rebase_state_range(from_seq: int, to_seq: int, position_delta: Vector3, rotation_delta: Quaternion, linear_velocity_delta: Vector3, angular_velocity_delta: Vector3) -> void:
|
||||||
|
for seq in range(from_seq, to_seq + 1):
|
||||||
|
var prediction := get_prediction(seq)
|
||||||
|
if prediction.is_empty() or bool(prediction.get("unsimulated", false)):
|
||||||
|
continue
|
||||||
|
var state: NetBodyState = prediction["state"]
|
||||||
|
state.position += position_delta
|
||||||
|
state.rotation = (rotation_delta * state.rotation).normalized()
|
||||||
|
state.linear_velocity += linear_velocity_delta
|
||||||
|
state.angular_velocity += angular_velocity_delta
|
||||||
|
overwrite_state(seq, state)
|
||||||
|
|
||||||
|
|
||||||
|
# Produces comparison data only. Applying a snap, teleport, velocity delta,
|
||||||
|
# or visual offset belongs to later Phase 4 tasks.
|
||||||
|
func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
|
||||||
|
if seq > last_acknowledged_seq:
|
||||||
|
last_acknowledged_seq = seq
|
||||||
|
var prediction := get_prediction(seq)
|
||||||
|
if prediction.is_empty():
|
||||||
|
return {
|
||||||
|
"status": _missing_status(seq),
|
||||||
|
"seq": seq,
|
||||||
|
"authoritative_state": authoritative.copy(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# A successful match is the only evidence that the acknowledgement clock
|
||||||
|
# has genuinely caught back up, so it is the only thing allowed to clear
|
||||||
|
# resync_required — a "missing_evicted"/"missing_not_recorded" result
|
||||||
|
# proves the opposite, and must leave the flag alone.
|
||||||
|
#
|
||||||
|
# The extra span check is not redundant. record() is not guaranteed to be
|
||||||
|
# called with consecutive sequences: input_lead_controller.update() can
|
||||||
|
# return 0 or up to 1+3, so the client's seq can skip forward, leaving a
|
||||||
|
# ring slot holding a tag OLDER than newest_recorded_seq - RING_SIZE
|
||||||
|
# (its residue was simply never rewritten). get_prediction() would still
|
||||||
|
# report that as "matched", so matching alone does not imply the
|
||||||
|
# outstanding window is back within capacity. Gate on the exact inverse
|
||||||
|
# of record()'s own trip inequality instead, which holds regardless of
|
||||||
|
# how sparsely sequences were recorded.
|
||||||
|
if newest_recorded_seq - last_acknowledged_seq <= RING_SIZE:
|
||||||
|
resync_required = false
|
||||||
|
|
||||||
|
if bool(prediction.get("unsimulated", false)):
|
||||||
|
# Reaching this sequence at all proves the acknowledgement clock is
|
||||||
|
# healthy — the entry is present and correctly tagged — so the
|
||||||
|
# resync_required clear above still applies. There is simply nothing
|
||||||
|
# to compare, because the client never simulated this sequence.
|
||||||
|
return {
|
||||||
|
"status": "unsimulated_gap",
|
||||||
|
"seq": seq,
|
||||||
|
"action": prediction["action"],
|
||||||
|
"authoritative_state": authoritative.copy(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var predicted_state: NetBodyState = prediction["state"]
|
||||||
|
var position_error := authoritative.position - predicted_state.position
|
||||||
|
var rotation_error_radians := predicted_state.rotation.angle_to(authoritative.rotation)
|
||||||
|
return {
|
||||||
|
"status": "matched",
|
||||||
|
"seq": seq,
|
||||||
|
"action": prediction["action"],
|
||||||
|
"predicted_state": predicted_state,
|
||||||
|
"authoritative_state": authoritative.copy(),
|
||||||
|
"position_error": position_error,
|
||||||
|
"position_error_magnitude": position_error.length(),
|
||||||
|
"rotation_error_radians": rotation_error_radians,
|
||||||
|
"rotation_error_degrees": rad_to_deg(rotation_error_radians),
|
||||||
|
"linear_velocity_error": authoritative.linear_velocity - predicted_state.linear_velocity,
|
||||||
|
"angular_velocity_error": authoritative.angular_velocity - predicted_state.angular_velocity,
|
||||||
|
"contact_window": bool(prediction.get("contact_window", false)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _missing_status(seq: int) -> String:
|
||||||
|
# The server starts its acknowledgement clock at sequence 0, while the
|
||||||
|
# first local post-step prediction is normally sequence 1. This is a normal
|
||||||
|
# startup boundary, not a lost ring entry and must not trigger a hard snap.
|
||||||
|
if not _has_recorded or seq < _first_recorded_seq:
|
||||||
|
return "warmup_not_recorded"
|
||||||
|
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
|
||||||
|
return "missing_evicted"
|
||||||
|
return "missing_not_recorded"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://goarfpbthyf6
|
||||||
+142
-10
@@ -13,16 +13,15 @@ const BOTS_DIR := "res://bots"
|
|||||||
|
|
||||||
# Every tier runs its promoted checkpoint at full trained capability —
|
# Every tier runs its promoted checkpoint at full trained capability —
|
||||||
# difficulty is a genuinely different policy, never the same policy
|
# difficulty is a genuinely different policy, never the same policy
|
||||||
# handicapped with reaction delay or action noise. Easy and Medium are now
|
# handicapped with reaction delay or action noise. All three tiers are now
|
||||||
# distinct models (medium.json beats easy.json 65-22-13 head-to-head); Hard
|
# distinct models: medium.json beats easy.json 65-22-13, and hard.json (the
|
||||||
# still points at medium.json, the strongest promoted policy, and stays a
|
# generation-5 Stage-5 policy) beats medium.json 47-32-21. Keep the tiers
|
||||||
# label-only duplicate until a stronger one earns hard.json. Keep the tiers
|
|
||||||
# monotonic: never leave a lower tier pointing at a stronger model than the
|
# monotonic: never leave a lower tier pointing at a stronger model than the
|
||||||
# one above it.
|
# one above it.
|
||||||
const DIFFICULTIES := [
|
const DIFFICULTIES := [
|
||||||
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 8, "action_noise": 0.0},
|
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||||
{"name": "Medium", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0},
|
{"name": "Medium", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||||
{"name": "Hard", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0},
|
{"name": "Hard", "model": "res://bots/promoted/hard.json", "reaction_ticks": 8, "action_noise": 0.0},
|
||||||
]
|
]
|
||||||
|
|
||||||
@onready var difficulty_dropdown: OptionButton = %DifficultyDropdown
|
@onready var difficulty_dropdown: OptionButton = %DifficultyDropdown
|
||||||
@@ -31,9 +30,18 @@ const DIFFICULTIES := [
|
|||||||
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
|
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
|
||||||
@onready var bot_a_dropdown: OptionButton = %BotADropdown
|
@onready var bot_a_dropdown: OptionButton = %BotADropdown
|
||||||
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
|
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
|
||||||
|
@onready var join_address_edit: LineEdit = %JoinAddressEdit
|
||||||
|
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
|
||||||
|
@onready var connecting_overlay: Control = %ConnectingOverlay
|
||||||
|
@onready var connecting_status_label: Label = %ConnectingStatusLabel
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
AudioManager.bind_tree_buttons(self)
|
||||||
|
# An idle menu has no reason to render past the display's own refresh
|
||||||
|
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
|
||||||
|
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
||||||
|
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
|
||||||
_populate_difficulty_dropdown()
|
_populate_difficulty_dropdown()
|
||||||
_populate_arena_dropdown()
|
_populate_arena_dropdown()
|
||||||
dev_section.visible = OS.is_debug_build()
|
dev_section.visible = OS.is_debug_build()
|
||||||
@@ -42,7 +50,25 @@ func _ready() -> void:
|
|||||||
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
|
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
|
||||||
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
|
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
|
||||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||||
|
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||||
|
%FreePlayButton.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||||
|
# (NetworkManager.host() either succeeds immediately or fails immediately),
|
||||||
|
# but Join is not — it can take anywhere from a clean local-network round
|
||||||
|
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
|
||||||
|
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
|
||||||
|
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
|
||||||
|
# path that returns the player to a sane, retryable menu state rather than
|
||||||
|
# just hanging with no feedback.
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
NetworkManager.poll()
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
NetworkManager.poll()
|
||||||
|
|
||||||
|
|
||||||
func _populate_difficulty_dropdown() -> void:
|
func _populate_difficulty_dropdown() -> void:
|
||||||
@@ -94,6 +120,11 @@ func _list_bots() -> Array[String]:
|
|||||||
# disk, else the newest (last) bot.
|
# disk, else the newest (last) bot.
|
||||||
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
|
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
|
||||||
dropdown.clear()
|
dropdown.clear()
|
||||||
|
# These are filled from whatever checkpoints happen to be in res://bots, so
|
||||||
|
# a long filename would otherwise widen the OptionButton (size_flags_h =
|
||||||
|
# EXPAND_FILL) and drag the whole menu past its 420px minimum width.
|
||||||
|
dropdown.clip_text = true
|
||||||
|
dropdown.fit_to_longest_item = false
|
||||||
if include_none:
|
if include_none:
|
||||||
dropdown.add_item("(Use difficulty)")
|
dropdown.add_item("(Use difficulty)")
|
||||||
dropdown.set_item_metadata(0, "")
|
dropdown.set_item_metadata(0, "")
|
||||||
@@ -115,10 +146,19 @@ func _selected_path(dropdown: OptionButton) -> String:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# The menu's own refresh-rate fps cap (see _ready) is a menu-only concern;
|
||||||
|
# gameplay scenes respect the player's own VideoSettings fps cap instead
|
||||||
|
# (task 0.17), which only actually caps anything when vsync is Disabled and a
|
||||||
|
# divisor is chosen — otherwise this uncaps exactly like the old hardcoded 0.
|
||||||
|
func _leave_to_gameplay(scene_path: String) -> void:
|
||||||
|
VideoSettings.apply_fps_cap()
|
||||||
|
get_tree().change_scene_to_file(scene_path)
|
||||||
|
|
||||||
|
|
||||||
func _on_free_play_pressed() -> void:
|
func _on_free_play_pressed() -> void:
|
||||||
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
|
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
|
||||||
GameSettings.selected_arena_path = chosen["path"]
|
GameSettings.selected_arena_path = chosen["path"]
|
||||||
get_tree().change_scene_to_file("res://scenes/free_play.tscn")
|
_leave_to_gameplay("res://scenes/free_play.tscn")
|
||||||
|
|
||||||
|
|
||||||
func _on_match_pressed() -> void:
|
func _on_match_pressed() -> void:
|
||||||
@@ -134,14 +174,106 @@ func _on_match_pressed() -> void:
|
|||||||
GameSettings.selected_bot_path = override_path
|
GameSettings.selected_bot_path = override_path
|
||||||
GameSettings.selected_bot_reaction_ticks = -1
|
GameSettings.selected_bot_reaction_ticks = -1
|
||||||
GameSettings.selected_bot_action_noise = -1.0
|
GameSettings.selected_bot_action_noise = -1.0
|
||||||
get_tree().change_scene_to_file("res://scenes/match.tscn")
|
_leave_to_gameplay("res://scenes/match.tscn")
|
||||||
|
|
||||||
|
|
||||||
func _on_settings_pressed() -> void:
|
func _on_settings_pressed() -> void:
|
||||||
get_tree().change_scene_to_file("res://scenes/settings.tscn")
|
get_tree().change_scene_to_file(ScenePaths.SETTINGS)
|
||||||
|
|
||||||
|
|
||||||
func _on_spectate_pressed() -> void:
|
func _on_spectate_pressed() -> void:
|
||||||
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
||||||
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
||||||
get_tree().change_scene_to_file("res://scenes/spectate.tscn")
|
_leave_to_gameplay("res://scenes/spectate.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_host_pressed() -> void:
|
||||||
|
_clear_multiplayer_error()
|
||||||
|
var err := NetworkManager.host()
|
||||||
|
if err != OK:
|
||||||
|
_show_multiplayer_error("Could not host: %s" % error_string(err))
|
||||||
|
return
|
||||||
|
_leave_to_lobby()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_find_match_pressed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/matchmaking.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_join_pressed() -> void:
|
||||||
|
_start_join()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_join_address_submitted(_new_text: String) -> void:
|
||||||
|
_start_join()
|
||||||
|
|
||||||
|
|
||||||
|
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
|
||||||
|
# anything a menu should make a player wait for — verified empirically
|
||||||
|
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
|
||||||
|
# refused loopback connection: connection_failed never fired within 14s.
|
||||||
|
# This timer is what actually guarantees "connection-refused reaches a sane
|
||||||
|
# UI state" rather than leaving the overlay up indefinitely.
|
||||||
|
const CONNECT_TIMEOUT_SECONDS := 6.0
|
||||||
|
|
||||||
|
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
|
||||||
|
|
||||||
|
|
||||||
|
func _start_join() -> void:
|
||||||
|
_clear_multiplayer_error()
|
||||||
|
var address := join_address_edit.text.strip_edges()
|
||||||
|
if address.is_empty():
|
||||||
|
_show_multiplayer_error("Enter an IP address to join")
|
||||||
|
return
|
||||||
|
var err := NetworkManager.join(address)
|
||||||
|
if err != OK:
|
||||||
|
_show_multiplayer_error("Could not join: %s" % error_string(err))
|
||||||
|
return
|
||||||
|
connecting_status_label.text = "Connecting to %s..." % address
|
||||||
|
connecting_overlay.visible = true
|
||||||
|
_connect_timeout_token += 1
|
||||||
|
var my_token := _connect_timeout_token
|
||||||
|
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connect_timeout(token: int) -> void:
|
||||||
|
if token != _connect_timeout_token or not connecting_overlay.visible:
|
||||||
|
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
|
||||||
|
NetworkManager.shutdown()
|
||||||
|
connecting_overlay.visible = false
|
||||||
|
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connecting_cancel_pressed() -> void:
|
||||||
|
_connect_timeout_token += 1
|
||||||
|
NetworkManager.shutdown()
|
||||||
|
connecting_overlay.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connected_to_server() -> void:
|
||||||
|
if not connecting_overlay.visible:
|
||||||
|
return # e.g. a stray/late signal after Cancel already shut the peer down
|
||||||
|
_connect_timeout_token += 1
|
||||||
|
connecting_overlay.visible = false
|
||||||
|
_leave_to_lobby()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connection_failed() -> void:
|
||||||
|
if not connecting_overlay.visible:
|
||||||
|
return
|
||||||
|
_connect_timeout_token += 1
|
||||||
|
connecting_overlay.visible = false
|
||||||
|
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
|
||||||
|
|
||||||
|
|
||||||
|
func _leave_to_lobby() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _show_multiplayer_error(message: String) -> void:
|
||||||
|
multiplayer_error_label.text = message
|
||||||
|
multiplayer_error_label.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_multiplayer_error() -> void:
|
||||||
|
multiplayer_error_label.visible = false
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func _run_kickoff_countdown() -> void:
|
|||||||
_set_frozen(true)
|
_set_frozen(true)
|
||||||
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
|
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
|
||||||
kickoff_countdown.emit(count)
|
kickoff_countdown.emit(count)
|
||||||
|
AudioManager.play_countdown(count)
|
||||||
# process_always=false: if full-time fires mid-countdown (see
|
# process_always=false: if full-time fires mid-countdown (see
|
||||||
# _on_match_timer_timeout's get_tree().paused = true), this stalls
|
# _on_match_timer_timeout's get_tree().paused = true), this stalls
|
||||||
# harmlessly in lockstep with the pause instead of ticking a
|
# harmlessly in lockstep with the pause instead of ticking a
|
||||||
@@ -132,6 +133,7 @@ func _run_kickoff_countdown() -> void:
|
|||||||
if _match_over:
|
if _match_over:
|
||||||
return
|
return
|
||||||
kickoff_countdown.emit(0)
|
kickoff_countdown.emit(0)
|
||||||
|
AudioManager.play_countdown(0)
|
||||||
_set_frozen(false)
|
_set_frozen(false)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
|
||||||
|
# top of NetworkManager's raw transport (§2.5, §1.3 of MULTIPLAYER_SPEC.md).
|
||||||
|
# hello/welcome, strict protocol_version and physics_ticks_per_second
|
||||||
|
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
|
||||||
|
# somewhere durable to keep it across the lobby→match scene transition —
|
||||||
|
# each player's team and ready state. Slot assignment (fixed spawn index
|
||||||
|
# within a team) is NOT here; that's match spawn's job in Phase 2, derived
|
||||||
|
# from this roster's team field at spawn time, not stored redundantly here.
|
||||||
|
|
||||||
|
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||||
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||||
|
const AssignmentState = preload("res://scripts/assignment_state.gd")
|
||||||
|
|
||||||
|
signal player_joined(peer_id: int, player_name: String)
|
||||||
|
signal player_left(peer_id: int)
|
||||||
|
signal player_state_changed(peer_id: int, team: int, ready: bool)
|
||||||
|
signal rejected(reason: String) # client-side only: the server refused our hello
|
||||||
|
signal welcomed() # client-side only: our hello was accepted
|
||||||
|
signal server_shutdown(reason: String) # client-side notification before planned close
|
||||||
|
signal result_submission_accepted
|
||||||
|
signal result_submission_retrying(http_code: int)
|
||||||
|
|
||||||
|
const TEAM_COUNT := 2
|
||||||
|
const RECONNECT_GRACE_SECONDS := 60.0
|
||||||
|
|
||||||
|
# player_name is the one client-supplied value in _hello that gets broadcast
|
||||||
|
# verbatim to every other peer (protocol_version/tick_hz are checked, never
|
||||||
|
# relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching
|
||||||
|
# the string at all — a legitimate client only ever sends local_player_name,
|
||||||
|
# which the UI already keeps short, so anything past this is a bug or an
|
||||||
|
# attacker, not a real name to truncate politely. Adversarial review found
|
||||||
|
# an unbounded name relayed to every peer head-of-line-blocks the reliable
|
||||||
|
# control channel hard enough that a concurrently-joining client's own
|
||||||
|
# _welcome never arrived — this is what closes that.
|
||||||
|
const MAX_INPUT_LENGTH := 256
|
||||||
|
const MAX_PLAYER_NAME_LENGTH := 24
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerInfo:
|
||||||
|
var peer_id: int
|
||||||
|
var player_name: String
|
||||||
|
var player_identity: String
|
||||||
|
var team: int = 0
|
||||||
|
var spawn_index: int = -1
|
||||||
|
var ready: bool = false
|
||||||
|
|
||||||
|
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
|
||||||
|
peer_id = p_peer_id
|
||||||
|
player_name = p_player_name
|
||||||
|
player_identity = p_player_identity
|
||||||
|
team = p_team
|
||||||
|
ready = p_ready
|
||||||
|
|
||||||
|
|
||||||
|
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
|
||||||
|
var local_player_name := "Player"
|
||||||
|
var last_server_shutdown_reason := ""
|
||||||
|
# Set by the assignment connection path. Direct-IP/community-server joins keep
|
||||||
|
# this empty for backwards compatibility; allocated matches carry the opaque
|
||||||
|
# signed authorisation in hello rather than putting it in the endpoint URL.
|
||||||
|
var join_authorisation := ""
|
||||||
|
var require_join_authorisation := false
|
||||||
|
var admissions_open := true
|
||||||
|
var _allowed_join_authorisations: Dictionary = {}
|
||||||
|
var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id
|
||||||
|
var _join_history: Dictionary = {} # token -> {generation, lost_at}
|
||||||
|
var _join_authorisation_context: Dictionary = {}
|
||||||
|
# Key ID -> raw HMAC key. A set rather than a single key so a signing-key
|
||||||
|
# rotation does not invalidate authorisations already issued for in-flight
|
||||||
|
# matches: the allocator signs with the new key while servers still accept
|
||||||
|
# both, and the old key is dropped once no live match can reference it.
|
||||||
|
var _join_signing_keys := {}
|
||||||
|
var _connection_lease_claim := Callable()
|
||||||
|
var _connection_lease_disconnect := Callable()
|
||||||
|
var _result_submit := Callable()
|
||||||
|
|
||||||
|
# Test hook (tests/match_net_smoke.gd): set false before connecting to
|
||||||
|
# suppress the automatic real hello, so a test can send a deliberately
|
||||||
|
# mismatched one instead to exercise the rejection path.
|
||||||
|
var _auto_hello := true
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
NetworkManager.client_disconnected.connect(_on_peer_disconnected)
|
||||||
|
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||||
|
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||||
|
NetworkManager.shutting_down.connect(_on_shutting_down)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connected_to_server() -> void:
|
||||||
|
roster.clear()
|
||||||
|
last_server_shutdown_reason = ""
|
||||||
|
if _auto_hello:
|
||||||
|
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_disconnected_from_server() -> void:
|
||||||
|
roster.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Covers the case _on_disconnected_from_server doesn't: a HOST calling
|
||||||
|
# NetworkManager.shutdown() itself (Leave, or hosting again after already
|
||||||
|
# hosting) never fires disconnected_from_server — that signal only fires
|
||||||
|
# from an incoming multiplayer.server_disconnected event, which a server
|
||||||
|
# never receives about itself. Without this, roster (and every peer's team/
|
||||||
|
# ready state in it) would persist forever across a host/re-host cycle in
|
||||||
|
# the same process.
|
||||||
|
func _on_shutting_down() -> void:
|
||||||
|
roster.clear()
|
||||||
|
_allowed_join_authorisations.clear()
|
||||||
|
_active_join_peers.clear()
|
||||||
|
_join_history.clear()
|
||||||
|
_join_authorisation_context.clear()
|
||||||
|
_join_signing_keys = {}
|
||||||
|
_connection_lease_claim = Callable()
|
||||||
|
_connection_lease_disconnect = Callable()
|
||||||
|
_result_submit = Callable()
|
||||||
|
require_join_authorisation = false
|
||||||
|
admissions_open = true
|
||||||
|
|
||||||
|
|
||||||
|
# signing_keys maps key ID to raw key bytes. An empty dictionary disables
|
||||||
|
# signature verification, which is only valid for local/direct-hosted play.
|
||||||
|
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_keys: Dictionary = {}) -> bool:
|
||||||
|
var allowed := {}
|
||||||
|
for token in tokens:
|
||||||
|
if not token is String or String(token).is_empty():
|
||||||
|
return false
|
||||||
|
allowed[String(token)] = true
|
||||||
|
if allowed.is_empty() or not context.has("match_id") or not context["match_id"] is String or String(context["match_id"]).is_empty() or not context.has("server_id") or not context["server_id"] is String or String(context["server_id"]).is_empty() or not context.has("protocol_version") or not _valid_integer_claim(context["protocol_version"]) or int(context["protocol_version"]) < 1:
|
||||||
|
return false
|
||||||
|
_allowed_join_authorisations = allowed
|
||||||
|
_join_authorisation_context = context.duplicate(true)
|
||||||
|
_join_signing_keys = {}
|
||||||
|
for key_id in signing_keys:
|
||||||
|
var raw = signing_keys[key_id]
|
||||||
|
if not raw is PackedByteArray or PackedByteArray(raw).is_empty():
|
||||||
|
return false
|
||||||
|
_join_signing_keys[str(key_id)] = PackedByteArray(raw).duplicate()
|
||||||
|
require_join_authorisation = true
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func assigned_player_slots() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
var seen_identities := {}
|
||||||
|
var seen_slots := {}
|
||||||
|
for token in _allowed_join_authorisations.keys():
|
||||||
|
var claims := _join_claims(String(token))
|
||||||
|
if claims.is_empty():
|
||||||
|
return []
|
||||||
|
var identity := str(claims.get("PlayerID", ""))
|
||||||
|
var team := int(claims.get("Team", -1))
|
||||||
|
var slot := int(claims.get("Slot", -1))
|
||||||
|
if identity.is_empty() or team < 0 or team >= TEAM_COUNT or slot < 0 or slot > 5 or slot / 3 != team or seen_identities.has(identity) or seen_slots.has(slot):
|
||||||
|
return []
|
||||||
|
seen_identities[identity] = true
|
||||||
|
seen_slots[slot] = true
|
||||||
|
result.append({
|
||||||
|
"player_identity": identity,
|
||||||
|
"team": team,
|
||||||
|
"slot": slot,
|
||||||
|
})
|
||||||
|
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func player_identity(peer_id: int) -> String:
|
||||||
|
if not roster.has(peer_id):
|
||||||
|
return ""
|
||||||
|
return String((roster[peer_id] as PlayerInfo).player_identity)
|
||||||
|
|
||||||
|
|
||||||
|
static func reservation_identity_matches(slot_identity: String, incoming_identity: String, slot_name: String, incoming_name: String) -> bool:
|
||||||
|
# Authenticated allocations must never fall back to a client-chosen display
|
||||||
|
# name. The name fallback exists only for direct, unauthenticated servers.
|
||||||
|
if not slot_identity.is_empty() or not incoming_identity.is_empty():
|
||||||
|
return not slot_identity.is_empty() and slot_identity == incoming_identity
|
||||||
|
return slot_name == incoming_name
|
||||||
|
|
||||||
|
|
||||||
|
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
|
||||||
|
# proper hello just needs its (possibly absent) roster entry cleaned up.
|
||||||
|
# The normal leave path also goes through here after the server erases it,
|
||||||
|
# guarded by roster.erase()'s own has-check below.
|
||||||
|
func _on_peer_disconnected(peer_id: int) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
_cleanup_disconnected_peer(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _cleanup_disconnected_peer(peer_id: int) -> void:
|
||||||
|
NetworkManager.invalidate_peer(peer_id)
|
||||||
|
_remove_player(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _remove_player(peer_id: int) -> void:
|
||||||
|
for token in _active_join_peers.keys():
|
||||||
|
if int(_active_join_peers[token]) == peer_id:
|
||||||
|
_active_join_peers.erase(token)
|
||||||
|
var history: Dictionary = _join_history.get(token, {})
|
||||||
|
history["lost_at"] = Time.get_unix_time_from_system()
|
||||||
|
_join_history[token] = history
|
||||||
|
if _connection_lease_disconnect.is_valid():
|
||||||
|
_connection_lease_disconnect.call(_join_identity(token), int(history.get("generation", 0)))
|
||||||
|
break
|
||||||
|
if not roster.has(peer_id):
|
||||||
|
return
|
||||||
|
roster.erase(peer_id)
|
||||||
|
player_left.emit(peer_id)
|
||||||
|
# rpc() broadcasts to every peer in multiplayer.get_peers() — including,
|
||||||
|
# transiently, the very peer that just disconnected: this fires from
|
||||||
|
# NetworkManager's client_disconnected signal, and empirically that
|
||||||
|
# peer's own ENetConnection can still be momentarily present in the
|
||||||
|
# broadcast's target set with its channels already torn down, which
|
||||||
|
# logs "Unable to send packet on channel 0, max channels: 0" on every
|
||||||
|
# single disconnect (found by a second adversarial review — harmless to
|
||||||
|
# the game, since the departing peer obviously doesn't need to hear
|
||||||
|
# about its own departure, but it meant "clean stderr" wasn't actually
|
||||||
|
# clean for any test in this project).
|
||||||
|
#
|
||||||
|
# A first attempt filtered the broadcast down to rpc_id() calls that
|
||||||
|
# explicitly skip `peer_id`. That's necessary but not sufficient: when
|
||||||
|
# two peers disconnect within the same poll() batch (both bots quitting
|
||||||
|
# at the end of a CI run land within the same tick), get_peers() here
|
||||||
|
# can still list the SECOND peer as connected while its own disconnect
|
||||||
|
# event just hasn't been dispatched yet in this same batch — sending to
|
||||||
|
# it hits the identical error, one hop later. Defer the whole
|
||||||
|
# notification to the next idle frame instead of sending synchronously
|
||||||
|
# from inside signal-handling: by then poll() has fully returned, every
|
||||||
|
# disconnect event in this batch has been dispatched, and get_peers()
|
||||||
|
# reflects the settled, genuinely-still-connected set.
|
||||||
|
if is_inside_tree():
|
||||||
|
call_deferred("_broadcast_player_left", peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _broadcast_player_left(peer_id: int) -> void:
|
||||||
|
for other_peer_id in multiplayer.get_peers():
|
||||||
|
if other_peer_id != peer_id:
|
||||||
|
_player_left.rpc_id(other_peer_id, peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func broadcast_server_shutdown(reason: String) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var safe_reason := _sanitize_shutdown_reason(reason)
|
||||||
|
for peer_id in multiplayer.get_peers():
|
||||||
|
_server_shutdown.rpc_id(peer_id, safe_reason)
|
||||||
|
|
||||||
|
|
||||||
|
static func _sanitize_shutdown_reason(raw: String) -> String:
|
||||||
|
var clean := ""
|
||||||
|
for c in raw:
|
||||||
|
var code := c.unicode_at(0)
|
||||||
|
if code >= 0x20 and code != 0x7F:
|
||||||
|
clean += c
|
||||||
|
clean = clean.strip_edges()
|
||||||
|
if clean.length() > 96:
|
||||||
|
clean = clean.substr(0, 96)
|
||||||
|
return clean if not clean.is_empty() else "server_shutdown"
|
||||||
|
|
||||||
|
|
||||||
|
static func admission_rejection(is_open: bool) -> String:
|
||||||
|
return "" if is_open else "server is draining"
|
||||||
|
|
||||||
|
|
||||||
|
# Balances a new joiner onto whichever team currently has fewer players
|
||||||
|
# (ties go to team 0). Server only.
|
||||||
|
func _pick_balanced_team() -> int:
|
||||||
|
var counts := []
|
||||||
|
counts.resize(TEAM_COUNT)
|
||||||
|
counts.fill(0)
|
||||||
|
for info: PlayerInfo in roster.values():
|
||||||
|
counts[info.team] += 1
|
||||||
|
var best_team := 0
|
||||||
|
for team in range(TEAM_COUNT):
|
||||||
|
if counts[team] < counts[best_team]:
|
||||||
|
best_team = team
|
||||||
|
return best_team
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_join_authorisation: String = "") -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var peer_id := multiplayer.get_remote_sender_id()
|
||||||
|
if roster.has(peer_id):
|
||||||
|
return # duplicate hello from an already-accepted peer; ignore
|
||||||
|
var admission_error := admission_rejection(admissions_open)
|
||||||
|
if not admission_error.is_empty():
|
||||||
|
await _reject(peer_id, admission_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
if protocol_version != NetCodec.PROTOCOL_VERSION:
|
||||||
|
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
|
||||||
|
return
|
||||||
|
if tick_hz != SimConstants.TICK_HZ:
|
||||||
|
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
|
||||||
|
return
|
||||||
|
if require_join_authorisation and not _valid_join_authorisation(supplied_join_authorisation):
|
||||||
|
await _reject(peer_id, "join authorisation rejected")
|
||||||
|
return
|
||||||
|
if require_join_authorisation and _active_join_peers.has(supplied_join_authorisation):
|
||||||
|
await _reject(peer_id, "join authorisation already in use")
|
||||||
|
return
|
||||||
|
var join_generation := 1
|
||||||
|
if require_join_authorisation:
|
||||||
|
join_generation = await _claim_join_authorisation(supplied_join_authorisation, peer_id)
|
||||||
|
if join_generation < 0:
|
||||||
|
await _reject(peer_id, "join authorisation lease rejected")
|
||||||
|
return
|
||||||
|
if player_name.length() > MAX_INPUT_LENGTH:
|
||||||
|
await _reject(peer_id, "player name too long")
|
||||||
|
return
|
||||||
|
var clean_name := _sanitize_player_name(player_name)
|
||||||
|
var identity := _join_identity(supplied_join_authorisation) if require_join_authorisation else clean_name
|
||||||
|
if identity.is_empty():
|
||||||
|
await _reject(peer_id, "join authorisation rejected")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Tell the new peer about everyone already here before anyone is told
|
||||||
|
# about them, so no client ever observes an unknown peer_id in a
|
||||||
|
# player_joined it didn't get a prior player_joined for.
|
||||||
|
for existing_id: int in roster.keys():
|
||||||
|
var existing: PlayerInfo = roster[existing_id]
|
||||||
|
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
|
||||||
|
|
||||||
|
var team := _pick_balanced_team()
|
||||||
|
var spawn_index := -1
|
||||||
|
if require_join_authorisation:
|
||||||
|
var claims := _join_claims(supplied_join_authorisation)
|
||||||
|
var assigned_slot := int(claims.get("Slot", -1))
|
||||||
|
var assigned_team := int(claims.get("Team", -1))
|
||||||
|
if assigned_slot < 0 or assigned_slot > 5 or assigned_team < 0 or assigned_team >= TEAM_COUNT or assigned_slot / 3 != assigned_team:
|
||||||
|
await _reject(peer_id, "join authorisation rejected")
|
||||||
|
return
|
||||||
|
team = assigned_team
|
||||||
|
spawn_index = assigned_slot % 3
|
||||||
|
var info := PlayerInfo.new(peer_id, clean_name, team, false, identity)
|
||||||
|
info.spawn_index = spawn_index
|
||||||
|
roster[peer_id] = info
|
||||||
|
if require_join_authorisation:
|
||||||
|
# _reserve_join_authorisation already owns the active peer reservation;
|
||||||
|
# keeping the generation in the history makes fencing auditable without
|
||||||
|
# exposing it to the client.
|
||||||
|
_join_history[supplied_join_authorisation]["generation"] = join_generation
|
||||||
|
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
|
||||||
|
_welcome.rpc_id(peer_id)
|
||||||
|
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
|
||||||
|
|
||||||
|
|
||||||
|
func _valid_join_authorisation(token: String) -> bool:
|
||||||
|
if token.is_empty() or not _allowed_join_authorisations.has(token):
|
||||||
|
return false
|
||||||
|
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||||
|
while standard_token.length() % 4 != 0:
|
||||||
|
standard_token += "="
|
||||||
|
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||||
|
if decoded.is_empty():
|
||||||
|
return false
|
||||||
|
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||||
|
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope.has("Signature") or str(envelope["Signature"]).is_empty():
|
||||||
|
return false
|
||||||
|
var claims = envelope["Authorisation"]
|
||||||
|
if not claims is Dictionary:
|
||||||
|
return false
|
||||||
|
for string_claim in ["MatchID", "ServerID", "PlayerID", "SteamID", "Protocol", "ExpiresAt"]:
|
||||||
|
if not claims.has(string_claim) or not claims[string_claim] is String or String(claims[string_claim]).is_empty():
|
||||||
|
return false
|
||||||
|
for integer_claim in ["Slot", "Team", "Generation"]:
|
||||||
|
if not claims.has(integer_claim) or not _valid_integer_claim(claims[integer_claim]):
|
||||||
|
return false
|
||||||
|
if not envelope["Signature"] is String or String(envelope["Signature"]).is_empty():
|
||||||
|
return false
|
||||||
|
var claimed_team := int(claims.get("Team", -1))
|
||||||
|
var claimed_slot := int(claims.get("Slot", -1))
|
||||||
|
if claimed_team < 0 or claimed_team >= TEAM_COUNT or claimed_slot < 0 or claimed_slot > 5 or claimed_slot / 3 != claimed_team:
|
||||||
|
return false
|
||||||
|
var protocol := str(claims.get("Protocol", ""))
|
||||||
|
var expires_at := str(claims.get("ExpiresAt", ""))
|
||||||
|
if not AssignmentState.is_valid_expiry_timestamp(expires_at):
|
||||||
|
return false
|
||||||
|
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
|
||||||
|
if not _join_signing_keys.is_empty():
|
||||||
|
var signature_token := str(envelope["Signature"])
|
||||||
|
var signature := Marshalls.base64_to_raw(signature_token)
|
||||||
|
if signature.size() != 32:
|
||||||
|
return false
|
||||||
|
# The key ID selects which of the currently-valid keys signed this
|
||||||
|
# authorisation, so the allocator can rotate without invalidating
|
||||||
|
# authorisations already issued for in-flight matches. It is part of
|
||||||
|
# the signed bytes below, so pointing it at a different key simply
|
||||||
|
# fails verification rather than choosing a weaker key.
|
||||||
|
var key_id := str(claims.get("KeyID", ""))
|
||||||
|
if not _join_signing_keys.has(key_id):
|
||||||
|
return false
|
||||||
|
var signing_key: PackedByteArray = _join_signing_keys[key_id]
|
||||||
|
if signing_key.is_empty():
|
||||||
|
return false
|
||||||
|
var canonical := PackedByteArray()
|
||||||
|
# Must stay byte-identical to server/domain/join_auth.go's
|
||||||
|
# JoinAuthorisationBytes; the two change together or every join fails.
|
||||||
|
var fields := [
|
||||||
|
str(claims.get("MatchID", "")), str(claims.get("ServerID", "")),
|
||||||
|
str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")),
|
||||||
|
str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol,
|
||||||
|
str(int(claims.get("Generation", 0))), expires_at, key_id,
|
||||||
|
]
|
||||||
|
for index in fields.size():
|
||||||
|
canonical.append_array(String(fields[index]).to_utf8_buffer())
|
||||||
|
if index < fields.size() - 1:
|
||||||
|
canonical.append(0)
|
||||||
|
var hmac := HMACContext.new()
|
||||||
|
hmac.start(HashingContext.HASH_SHA256, signing_key)
|
||||||
|
hmac.update(canonical)
|
||||||
|
if hmac.finish() != signature:
|
||||||
|
return false
|
||||||
|
return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \
|
||||||
|
and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \
|
||||||
|
and protocol == str(_join_authorisation_context.get("protocol", "")) \
|
||||||
|
and int(claims.get("Slot", -1)) >= 0 and int(claims.get("Slot", -1)) <= 5 \
|
||||||
|
and expiry > Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
|
||||||
|
static func _valid_integer_claim(value: Variant) -> bool:
|
||||||
|
if value is int:
|
||||||
|
return int(value) >= 0
|
||||||
|
if value is float:
|
||||||
|
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _join_identity(token: String) -> String:
|
||||||
|
if token.is_empty():
|
||||||
|
return ""
|
||||||
|
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||||
|
while standard_token.length() % 4 != 0:
|
||||||
|
standard_token += "="
|
||||||
|
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||||
|
if decoded.is_empty():
|
||||||
|
return ""
|
||||||
|
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||||
|
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
|
||||||
|
return ""
|
||||||
|
return str(envelope["Authorisation"].get("PlayerID", ""))
|
||||||
|
|
||||||
|
|
||||||
|
func _join_claims(token: String) -> Dictionary:
|
||||||
|
if token.is_empty():
|
||||||
|
return {}
|
||||||
|
var standard_token := token.replace("-", "+").replace("_", "/")
|
||||||
|
while standard_token.length() % 4 != 0:
|
||||||
|
standard_token += "="
|
||||||
|
var decoded := Marshalls.base64_to_raw(standard_token)
|
||||||
|
if decoded.is_empty():
|
||||||
|
return {}
|
||||||
|
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
|
||||||
|
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
|
||||||
|
return {}
|
||||||
|
return envelope["Authorisation"]
|
||||||
|
|
||||||
|
|
||||||
|
func is_join_authorisation_active(token: String) -> bool:
|
||||||
|
return not token.is_empty() and _active_join_peers.has(token)
|
||||||
|
|
||||||
|
|
||||||
|
func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable) -> void:
|
||||||
|
_connection_lease_claim = claim
|
||||||
|
_connection_lease_disconnect = disconnect
|
||||||
|
|
||||||
|
|
||||||
|
func configure_result_submission(callback: Callable) -> void:
|
||||||
|
_result_submit = callback
|
||||||
|
|
||||||
|
|
||||||
|
func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool:
|
||||||
|
if not _result_submit.is_valid() or not score.has(0) or not score.has(1):
|
||||||
|
return false
|
||||||
|
_result_submit.call(int(score[0]), int(score[1]), integrity_state)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _claim_join_authorisation(token: String, peer_id: int) -> int:
|
||||||
|
var expected_generation := _available_join_generation(token)
|
||||||
|
if expected_generation < 0:
|
||||||
|
return -1
|
||||||
|
var generation := expected_generation + 1
|
||||||
|
if _connection_lease_claim.is_valid():
|
||||||
|
var response = await _connection_lease_claim.call(_join_identity(token), expected_generation)
|
||||||
|
generation = lease_claim_generation(response, expected_generation)
|
||||||
|
if generation < 0:
|
||||||
|
return -1
|
||||||
|
# The await above deliberately allows one bounded control-plane request.
|
||||||
|
# Re-evaluate every local fact that can change during that suspension before
|
||||||
|
# publishing the reservation. If a durable claim succeeded, close it again.
|
||||||
|
# A concurrent same-token hello can receive the same idempotent claim; its
|
||||||
|
# loser must not close the generation now owned by the local winner.
|
||||||
|
if _active_join_peers.has(token):
|
||||||
|
return -1
|
||||||
|
if not admissions_open or not _valid_join_authorisation(token) or peer_id not in multiplayer.get_peers():
|
||||||
|
if _connection_lease_disconnect.is_valid():
|
||||||
|
_connection_lease_disconnect.call(_join_identity(token), generation)
|
||||||
|
return -1
|
||||||
|
_join_history[token] = {"generation": generation, "lost_at": 0.0}
|
||||||
|
_active_join_peers[token] = peer_id
|
||||||
|
return generation
|
||||||
|
|
||||||
|
|
||||||
|
func _available_join_generation(token: String) -> int:
|
||||||
|
if token.is_empty() or _active_join_peers.has(token):
|
||||||
|
return -1
|
||||||
|
var now := Time.get_unix_time_from_system()
|
||||||
|
var history: Dictionary = _join_history.get(token, {})
|
||||||
|
var lost_at := float(history.get("lost_at", 0.0))
|
||||||
|
if lost_at > 0.0 and (now < lost_at or now - lost_at > RECONNECT_GRACE_SECONDS):
|
||||||
|
return -1
|
||||||
|
return int(history.get("generation", 0))
|
||||||
|
|
||||||
|
|
||||||
|
static func lease_claim_generation(response, expected_generation: int) -> int:
|
||||||
|
if not response is Dictionary or expected_generation < 0:
|
||||||
|
return -1
|
||||||
|
var status := String(response.get("status", ""))
|
||||||
|
if status not in ["claimed", "unavailable"] or not response.get("generation") is int:
|
||||||
|
return -1
|
||||||
|
var generation := int(response["generation"])
|
||||||
|
if status == "unavailable":
|
||||||
|
return generation if expected_generation > 0 and generation == expected_generation + 1 else -1
|
||||||
|
# A durable backend may return a later generation only to a fresh process
|
||||||
|
# recovering an already-disconnected lease. Locally known generations never
|
||||||
|
# skip, and outage fallback never invents a jump.
|
||||||
|
return generation if generation == expected_generation + 1 or (expected_generation == 0 and generation > 1) else -1
|
||||||
|
|
||||||
|
|
||||||
|
func _reserve_join_authorisation(token: String, peer_id: int) -> int:
|
||||||
|
var expected_generation := _available_join_generation(token)
|
||||||
|
if expected_generation < 0:
|
||||||
|
return -1
|
||||||
|
var generation := expected_generation + 1
|
||||||
|
_join_history[token] = {"generation": generation, "lost_at": 0.0}
|
||||||
|
_active_join_peers[token] = peer_id
|
||||||
|
return generation
|
||||||
|
|
||||||
|
|
||||||
|
# Strips control/formatting characters (so a name can't corrupt a log line
|
||||||
|
# or blow out UI layout with e.g. embedded newlines) and clamps to display
|
||||||
|
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
|
||||||
|
# this runs, so this never iterates an attacker-sized string. static: pure
|
||||||
|
# function of its argument, doesn't touch roster/multiplayer — also lets
|
||||||
|
# tests/cases/test_match_net.gd call it with no Node instantiation.
|
||||||
|
static func _sanitize_player_name(raw: String) -> String:
|
||||||
|
var clean := ""
|
||||||
|
for c in raw:
|
||||||
|
var code := c.unicode_at(0)
|
||||||
|
if code >= 0x20 and code != 0x7F:
|
||||||
|
clean += c
|
||||||
|
clean = clean.strip_edges()
|
||||||
|
if clean.length() > MAX_PLAYER_NAME_LENGTH:
|
||||||
|
clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH)
|
||||||
|
if clean.is_empty():
|
||||||
|
clean = "Player"
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
func _reject(peer_id: int, reason: String) -> void:
|
||||||
|
_rejected.rpc_id(peer_id, reason)
|
||||||
|
# §9 gotcha 26: a reliable RPC just queued still needs a beat of polling
|
||||||
|
# to actually reach the wire before we pull the connection out from
|
||||||
|
# under it.
|
||||||
|
await get_tree().create_timer(0.3).timeout
|
||||||
|
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||||
|
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Client-callable requests. Both are fire-and-forget: the authoritative
|
||||||
|
# change comes back through _state_changed once the server applies it, same
|
||||||
|
# as everyone else's — a client never mutates its own roster entry directly.
|
||||||
|
func request_set_team(team: int) -> void:
|
||||||
|
_set_team.rpc_id(1, team)
|
||||||
|
|
||||||
|
|
||||||
|
func request_set_ready(ready: bool) -> void:
|
||||||
|
_set_ready.rpc_id(1, ready)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _set_team(team: int) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var peer_id := multiplayer.get_remote_sender_id()
|
||||||
|
if not _apply_team_change(peer_id, team):
|
||||||
|
return
|
||||||
|
var info: PlayerInfo = roster[peer_id]
|
||||||
|
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||||
|
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_team_change(peer_id: int, team: int) -> bool:
|
||||||
|
# In allocated matches team and global slot are signed together. Changing
|
||||||
|
# only team would produce a roster that disagrees with the assignment and
|
||||||
|
# leave spawn_index anchored to the old team.
|
||||||
|
if require_join_authorisation:
|
||||||
|
return false
|
||||||
|
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
|
||||||
|
return false
|
||||||
|
var info: PlayerInfo = roster[peer_id]
|
||||||
|
if info.team == team:
|
||||||
|
return false
|
||||||
|
info.team = team
|
||||||
|
info.ready = false # switching teams un-readies — the roster you were ready against just changed
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _set_ready(ready: bool) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var peer_id := multiplayer.get_remote_sender_id()
|
||||||
|
if not roster.has(peer_id):
|
||||||
|
return
|
||||||
|
var info: PlayerInfo = roster[peer_id]
|
||||||
|
if info.ready == ready:
|
||||||
|
return
|
||||||
|
info.ready = ready
|
||||||
|
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||||
|
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _state_changed(peer_id: int, team: int, ready: bool) -> void:
|
||||||
|
if not roster.has(peer_id):
|
||||||
|
return
|
||||||
|
var info: PlayerInfo = roster[peer_id]
|
||||||
|
info.team = team
|
||||||
|
info.ready = ready
|
||||||
|
player_state_changed.emit(peer_id, team, ready)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _welcome() -> void:
|
||||||
|
welcomed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _rejected(reason: String) -> void:
|
||||||
|
rejected.emit(reason)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _server_shutdown(reason: String) -> void:
|
||||||
|
last_server_shutdown_reason = _sanitize_shutdown_reason(reason)
|
||||||
|
server_shutdown.emit(last_server_shutdown_reason)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
|
||||||
|
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
|
||||||
|
player_joined.emit(peer_id, player_name)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _player_left(peer_id: int) -> void:
|
||||||
|
if not roster.has(peer_id):
|
||||||
|
return
|
||||||
|
roster.erase(peer_id)
|
||||||
|
player_left.emit(peer_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b8300uu0s6jqt
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs:
|
||||||
|
# match_config (server assigns arena + deterministic slot order from
|
||||||
|
# MatchNet.roster), input (client -> server, per-tick action), snapshot
|
||||||
|
# (server -> client, NetCodec-packed body state), and a small score_update
|
||||||
|
# for the HUD. Lives on an autoload per §1.3's derived decision ("All
|
||||||
|
# hot-path RPCs live on autoloads") even though these are scoped to
|
||||||
|
# whichever match happens to be running — a scene-node RPC target would
|
||||||
|
# need matching NodePaths across peers, which an autoload sidesteps
|
||||||
|
# entirely, and it's what lets NetworkedMatch itself stay a plain scene
|
||||||
|
# node with no networking-identity concerns of its own.
|
||||||
|
#
|
||||||
|
# Channel intent per §2.1: 0 reliable (match_config, score_update), 1
|
||||||
|
# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet
|
||||||
|
# verified against ENet's own reserved system channel offset (§2.1's own
|
||||||
|
# "verify empirically" hedge); if that turns out to matter these indices
|
||||||
|
# will need adjusting, not the RPC design itself.
|
||||||
|
|
||||||
|
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||||
|
|
||||||
|
signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array)
|
||||||
|
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
|
||||||
|
# Task 5.10. A packet this autoload dropped before it could ever reach a match,
|
||||||
|
# with the verbatim bytes — the replay log's whole reason to exist is the field
|
||||||
|
# report "my input did nothing", and an accepted-input-only log has thrown away
|
||||||
|
# exactly the evidence that would explain it. `reason` is an InputRejectReason;
|
||||||
|
# the transport layer deliberately does not know about the replay format's own
|
||||||
|
# record kinds, so the mapping lives at the listener.
|
||||||
|
signal input_rejected(peer_id: int, reason: int, bytes: PackedByteArray)
|
||||||
|
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
|
||||||
|
signal score_update_received(score: Dictionary)
|
||||||
|
signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.State
|
||||||
|
# §6.2 step 6. positions/rotations are body-order: every slot in order, then
|
||||||
|
# the ball — the same order the snapshot uses, so one convention covers both.
|
||||||
|
# rotations is 4 floats per body (x, y, z, w).
|
||||||
|
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
|
||||||
|
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
|
||||||
|
signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int)
|
||||||
|
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int)
|
||||||
|
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
|
||||||
|
signal slot_assigned_received(peer_id: int, slot_index: int)
|
||||||
|
|
||||||
|
# Input validation (MULTIPLAYER_SPEC.md §3.1 steps 2-3; multiplayer-next.md task 3.4). Deliberately
|
||||||
|
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
|
||||||
|
# level concern independent of any particular match's roster/slot state, and
|
||||||
|
# this autoload already owns the RPC that receives the raw bytes.
|
||||||
|
#
|
||||||
|
# 60Hz * 1.5 + 20, per §3.1 step 2's own numbers.
|
||||||
|
const RATE_LIMIT_PACKETS_PER_SEC := 110
|
||||||
|
# "Same for a byte budget" (§3.1 step 2) — the worst-case legitimate packet
|
||||||
|
# is a full-redundancy input (INPUT_HEADER_SIZE + MAX_REDUNDANCY entries,
|
||||||
|
# the "40 B input" §2.3 sizes to), so the byte budget is just the packet
|
||||||
|
# budget scaled by that worst-case size — no separate constant to keep in
|
||||||
|
# sync by hand.
|
||||||
|
const RATE_LIMIT_BYTES_PER_SEC := RATE_LIMIT_PACKETS_PER_SEC * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
|
||||||
|
const RATE_LIMIT_WINDOW_MS := 1000
|
||||||
|
# Leaky-bucket excess tolerance, expressed in the same "N seconds' worth of
|
||||||
|
# budget" terms the original consecutive-streak design used. An adversarial
|
||||||
|
# review found that design — a streak counter that HARD-RESET to 0 on any
|
||||||
|
# single clean window — was trivially evaded by a duty-cycled flood (burst,
|
||||||
|
# then one clean window, repeat): reproduced sustaining ~33x the packet
|
||||||
|
# budget indefinitely with zero disconnect warnings. A leaky bucket doesn't
|
||||||
|
# care how the excess is distributed in time — see the window-roll logic
|
||||||
|
# below for how it accumulates and drains.
|
||||||
|
const RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT := RATE_LIMIT_PACKETS_PER_SEC * 3
|
||||||
|
const RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT := RATE_LIMIT_BYTES_PER_SEC * 3
|
||||||
|
const MALFORMED_LIMIT_TO_DISCONNECT := 20
|
||||||
|
# Task 5.10: how many rejected packets per peer per rate-limit window are
|
||||||
|
# forwarded to `input_rejected`. Sized so an honest client — whose rejects are
|
||||||
|
# occasional by definition, since a client rejected every tick is a bug the log
|
||||||
|
# is meant to catch — is never sampled away, while a flood cannot turn the log
|
||||||
|
# into unbounded attacker-controlled disk writes.
|
||||||
|
const REJECTS_RECORDED_PER_WINDOW := 8
|
||||||
|
|
||||||
|
# Server-stall grace (found by task 5.10's own reject recording, which is the
|
||||||
|
# only reason it was visible at all).
|
||||||
|
#
|
||||||
|
# When the server stalls — a 2s SIGSTOP stands in for a GC/IO/scheduler hitch —
|
||||||
|
# the client keeps sending at 60Hz throughout, and ENet delivers that entire
|
||||||
|
# backlog in the first window after resume. Measured: 70 of an HONEST client's
|
||||||
|
# input packets rejected as "rate limit exceeded", against a limit the client
|
||||||
|
# never came close to violating on its own. Redundancy does not cover it: the
|
||||||
|
# dropped packets are CONTIGUOUS, so each one's redundancy window falls inside
|
||||||
|
# the same dropped run — 0 of 70 were rescued, and 82 of 923 sequences (8.88%,
|
||||||
|
# ~1.4s of that player's input) never reached the server at all, versus 0.00%
|
||||||
|
# missing on an otherwise identical run with no stall. Every prediction gate
|
||||||
|
# still passed, which is exactly why this needed the log to find.
|
||||||
|
#
|
||||||
|
# So: don't rate-limit a backlog the server itself caused. The grace is capped,
|
||||||
|
# expires after two windows, and is granted only to peers already being
|
||||||
|
# tracked, so it cannot be farmed by a peer that connects during the stall. An
|
||||||
|
# attacker who can induce server stalls to earn budget already has a strictly
|
||||||
|
# worse capability than sending extra input packets.
|
||||||
|
const STALL_DETECT_MS := 250
|
||||||
|
const MAX_STALL_GRACE_PACKETS := SimConstants.TICK_HZ * 4 # 4s of a 60Hz client's backlog
|
||||||
|
const STALL_GRACE_WINDOWS := 2
|
||||||
|
|
||||||
|
enum InputRejectReason {
|
||||||
|
MALFORMED = 0,
|
||||||
|
RATE_LIMIT = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PeerInputState:
|
||||||
|
var window_start_ms := 0
|
||||||
|
var packets_this_window := 0
|
||||||
|
var bytes_this_window := 0
|
||||||
|
# Leaky bucket: grows by this window's actual total, drains by one
|
||||||
|
# window's worth of budget, every window — regardless of whether that
|
||||||
|
# window was itself over or under budget. A steady rate at or under
|
||||||
|
# budget nets to zero forever (never accumulates); any sustained AVERAGE
|
||||||
|
# above budget accumulates over time no matter how it's shaped into
|
||||||
|
# bursts, unlike a streak counter a clean gap can reset to 0.
|
||||||
|
var excess_packets := 0.0
|
||||||
|
var excess_bytes := 0.0
|
||||||
|
var malformed_count := 0
|
||||||
|
# Reject-recording budget for the current window. Without it the diagnostic
|
||||||
|
# is a remote disk-fill amplifier: the attacker chooses the flood rate, and
|
||||||
|
# every dropped packet would otherwise become a disk write. Capped per
|
||||||
|
# window, reset with the window itself, so an honest client's occasional
|
||||||
|
# reject is always captured while a flood contributes a bounded sample.
|
||||||
|
var rejects_recorded_this_window := 0
|
||||||
|
# Extra packets this peer may send before the limiter treats it as abuse,
|
||||||
|
# granted when the SERVER stalls and expiring shortly after.
|
||||||
|
var grace_packets := 0
|
||||||
|
var grace_windows_left := 0
|
||||||
|
var logged_rate_limit_this_window := false
|
||||||
|
|
||||||
|
|
||||||
|
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only
|
||||||
|
# Uncapped lifetime reject totals, so the sampled log can be read against the
|
||||||
|
# true figure — "8 rate-limit rejects recorded" means nothing on its own when
|
||||||
|
# the recorder itself stops at 8 per window. Deliberately NOT part of
|
||||||
|
# _PeerInputState, which is erased the moment a peer disconnects: a departed
|
||||||
|
# peer's reject history is exactly what the post-mortem wants, and the first
|
||||||
|
# version of this lost it (every summary printed an empty dictionary, because
|
||||||
|
# the client had always disconnected by the time the server tore the match
|
||||||
|
# down). peer_id -> {"malformed": int, "rate_limit": int}.
|
||||||
|
var _reject_totals: Dictionary = {}
|
||||||
|
var _last_physics_ms := 0
|
||||||
|
|
||||||
|
# Bandwidth (task 3.7's debug overlay): only the two 60Hz hot-path channels
|
||||||
|
# (input, snapshot) — match_config/score_update are low-frequency control
|
||||||
|
# messages, not what §2's byte-budget analysis or a live overlay cares
|
||||||
|
# about. Rolling per-second counters, recomputed opportunistically on each
|
||||||
|
# send/receive rather than on a timer — nothing needs the rate outside of
|
||||||
|
# an on-demand overlay read anyway. Use get_bytes_sent_per_sec() /
|
||||||
|
# get_bytes_received_per_sec() to READ these, not the raw fields directly
|
||||||
|
# — see those functions for why.
|
||||||
|
const BANDWIDTH_WINDOW_MS := 1000
|
||||||
|
var bytes_sent_per_sec := 0.0
|
||||||
|
var bytes_received_per_sec := 0.0
|
||||||
|
var _sent_window_start_ms := 0
|
||||||
|
var _sent_window_bytes := 0
|
||||||
|
var _received_window_start_ms := 0
|
||||||
|
var _received_window_bytes := 0
|
||||||
|
|
||||||
|
|
||||||
|
# An adversarial review found bytes_*_per_sec only ever gets recomputed
|
||||||
|
# INSIDE _track_sent()/_track_received() — i.e. only when traffic actually
|
||||||
|
# arrives — so if traffic stops entirely (right before a disconnect, or
|
||||||
|
# during exactly the kind of outage this overlay exists to diagnose), the
|
||||||
|
# last computed rate displays forever instead of decaying toward zero.
|
||||||
|
# Report zero once meaningfully more than one window has passed with
|
||||||
|
# nothing tracked, rather than trusting a stale field.
|
||||||
|
func get_bytes_sent_per_sec() -> float:
|
||||||
|
if Time.get_ticks_msec() - _sent_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
|
||||||
|
return 0.0
|
||||||
|
return bytes_sent_per_sec
|
||||||
|
|
||||||
|
|
||||||
|
func get_bytes_received_per_sec() -> float:
|
||||||
|
if Time.get_ticks_msec() - _received_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
|
||||||
|
return 0.0
|
||||||
|
return bytes_received_per_sec
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id))
|
||||||
|
# Seeded here, not left at 0, so the first physics frame measures a frame
|
||||||
|
# gap rather than the whole process uptime.
|
||||||
|
_last_physics_ms = Time.get_ticks_msec()
|
||||||
|
|
||||||
|
|
||||||
|
# Server-side stall watchdog. A SIGSTOPped or hitching process doesn't run this
|
||||||
|
# either, so the first physics frame after the stall is the one that sees the
|
||||||
|
# whole wall-clock gap — which is precisely the size of the client backlog
|
||||||
|
# about to arrive. Grace is handed only to peers ALREADY sending input, so a
|
||||||
|
# peer that connects during the stall gets none of it.
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
var gap := now - _last_physics_ms
|
||||||
|
_last_physics_ms = now
|
||||||
|
# NetworkManager.shutdown() swaps in an OfflineMultiplayerPeer before the
|
||||||
|
# smoke harness's deferred quit runs. Querying MultiplayerAPI.is_server()
|
||||||
|
# during that hand-off can call get_unique_id() on an inactive ENet peer and
|
||||||
|
# emit errors every physics frame; the NetworkManager role flag is the safe
|
||||||
|
# lifecycle guard at this boundary.
|
||||||
|
if not NetworkManager.is_server or _peer_input_state.is_empty():
|
||||||
|
return
|
||||||
|
if gap < STALL_DETECT_MS:
|
||||||
|
return
|
||||||
|
var credit: int = mini(int(float(gap) * SimConstants.TICK_HZ / 1000.0), MAX_STALL_GRACE_PACKETS)
|
||||||
|
for peer_id in _peer_input_state:
|
||||||
|
var state: _PeerInputState = _peer_input_state[peer_id]
|
||||||
|
state.grace_packets = mini(state.grace_packets + credit, MAX_STALL_GRACE_PACKETS)
|
||||||
|
state.grace_windows_left = STALL_GRACE_WINDOWS
|
||||||
|
push_warning("MatchSim: server stalled %dms — granting %d packets of rate-limit grace to %d peer(s)" % [
|
||||||
|
gap, credit, _peer_input_state.size()
|
||||||
|
])
|
||||||
|
ServerLog.warn("server_stalled", {"gap_ms": gap, "grace_packets": credit, "peers": _peer_input_state.size()})
|
||||||
|
|
||||||
|
|
||||||
|
func _track_sent(n: int) -> void:
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
if now - _sent_window_start_ms >= BANDWIDTH_WINDOW_MS:
|
||||||
|
bytes_sent_per_sec = _sent_window_bytes * 1000.0 / maxf(1.0, float(now - _sent_window_start_ms))
|
||||||
|
_sent_window_start_ms = now
|
||||||
|
_sent_window_bytes = 0
|
||||||
|
_sent_window_bytes += n
|
||||||
|
|
||||||
|
|
||||||
|
func _track_received(n: int) -> void:
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
if now - _received_window_start_ms >= BANDWIDTH_WINDOW_MS:
|
||||||
|
bytes_received_per_sec = _received_window_bytes * 1000.0 / maxf(1.0, float(now - _received_window_start_ms))
|
||||||
|
_received_window_start_ms = now
|
||||||
|
_received_window_bytes = 0
|
||||||
|
_received_window_bytes += n
|
||||||
|
|
||||||
|
# Server only: the last match_config actually sent, so a client whose own
|
||||||
|
# scene load (and therefore its match_config_received listener) finishes
|
||||||
|
# AFTER the server already broadcast can still get it — a one-shot
|
||||||
|
# broadcast alone is racy against however long the client takes to reach
|
||||||
|
# the point where it's listening, and Godot signals never buffer for a
|
||||||
|
# late connection. request_match_config() closes that race by turning
|
||||||
|
# delivery into "ask until you get it" instead of "hope you were already
|
||||||
|
# listening." Also covers a late joiner mid-match (Phase 5 will still need
|
||||||
|
# to add live match *state*, not just this static config, for that case).
|
||||||
|
var _last_match_config: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
||||||
|
_last_match_config = {
|
||||||
|
"arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices,
|
||||||
|
}
|
||||||
|
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
|
||||||
|
|
||||||
|
|
||||||
|
# Also the client's cue to ask for live match state — see
|
||||||
|
# NetworkedMatch._on_match_config_requested. A late joiner's bootstrap has the
|
||||||
|
# SAME race match_config has: the server sends it when the peer joins the
|
||||||
|
# roster, which is before that peer has loaded the match scene and connected
|
||||||
|
# its listeners, so a one-shot send is simply missed. Delivery has to be
|
||||||
|
# "ask until you get it" for both.
|
||||||
|
signal match_config_requested(peer_id: int)
|
||||||
|
|
||||||
|
|
||||||
|
func request_match_config() -> void:
|
||||||
|
_request_match_config.rpc_id(1)
|
||||||
|
|
||||||
|
|
||||||
|
func send_input(bytes: PackedByteArray) -> void:
|
||||||
|
_track_sent(bytes.size())
|
||||||
|
# bytes is already fully packed (any timestamps it carries are already
|
||||||
|
# fixed), so wrapping the dispatch itself is enough — task 2.8.
|
||||||
|
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
|
||||||
|
|
||||||
|
|
||||||
|
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
|
||||||
|
# A server-side disconnect can leave peer_id in get_peers() until the
|
||||||
|
# current poll batch settles. Do not enter Godot's RPC path for that stale
|
||||||
|
# target; NetSim repeats this check at fire time for delayed sends.
|
||||||
|
if not NetworkManager.can_send_to_peer(peer_id):
|
||||||
|
return
|
||||||
|
_track_sent(bytes.size())
|
||||||
|
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func send_score_update(score: Dictionary) -> void:
|
||||||
|
_score_update.rpc(score)
|
||||||
|
|
||||||
|
|
||||||
|
# §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the
|
||||||
|
# transition happened on rather than a duration — §6.2's closing note: on a
|
||||||
|
# lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a
|
||||||
|
# duration would then be applied from whenever it happened to arrive.
|
||||||
|
# The same state also rides every snapshot's match_state byte, so a client
|
||||||
|
# that misses this entirely still converges (see NetworkedMatch's own
|
||||||
|
# _on_snapshot_received) — this RPC exists to make the transition PROMPT and
|
||||||
|
# to carry `at_tick`, not to be the sole channel.
|
||||||
|
func send_state_change(state: int, at_tick: int) -> void:
|
||||||
|
_state_change.rpc(state, at_tick)
|
||||||
|
|
||||||
|
|
||||||
|
# §1's "seeded RNG for kickoff jitter" decision, enforced: the server sends the
|
||||||
|
# resulting TRANSFORMS, never a seed. Shared-seed determinism would require
|
||||||
|
# both sides to consume the RNG stream in identical order forever, and the
|
||||||
|
# first randf() anyone later adds to the reset path silently desyncs kickoff
|
||||||
|
# positions with no error message. A few hundred bytes once per kickoff cannot
|
||||||
|
# rot that way.
|
||||||
|
func send_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||||
|
_kickoff.rpc(positions, rotations, countdown_start_tick, reset_gen)
|
||||||
|
|
||||||
|
|
||||||
|
func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||||
|
_goal_scored.rpc(scoring_team, score, goal_tick, resume_tick)
|
||||||
|
|
||||||
|
|
||||||
|
# remaining_ticks is authoritative while `running` is false: a stopped clock
|
||||||
|
# cannot be derived from end_tick minus the current tick, or it drains through
|
||||||
|
# every goal pause and kickoff countdown.
|
||||||
|
func send_clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
|
||||||
|
_clock_state.rpc(running, end_tick, remaining_ticks, at_tick)
|
||||||
|
|
||||||
|
|
||||||
|
# §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match
|
||||||
|
# on arrival, sent to one peer rather than broadcast.
|
||||||
|
#
|
||||||
|
# match_config alone is not enough and never was: it carries arena and roster
|
||||||
|
# only, so a late joiner or a reconnecting player had no score, no clock, and
|
||||||
|
# no match state until the next goal or transition happened to fire. An
|
||||||
|
# adversarial review caught that; §6.2 step 2's `welcome` is specified to carry
|
||||||
|
# exactly this set, so this is that message under a name that does not clash
|
||||||
|
# with MatchNet's own lobby-level welcome.
|
||||||
|
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||||
|
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||||
|
|
||||||
|
|
||||||
|
# §6.3's late-joiner promotion. BROADCAST, not addressed to the new owner
|
||||||
|
# alone: every client holds its own copy of the slot list, and a peer_id that
|
||||||
|
# only the promoted client learns about leaves everyone else's copy naming a
|
||||||
|
# player who is no longer in that seat. Reliable channel 0 — a client that
|
||||||
|
# misses this keeps flying somebody else's ship as a remote body forever, and
|
||||||
|
# unlike match_state there is no per-snapshot field that would re-converge it.
|
||||||
|
func send_slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||||
|
_slot_assigned.rpc(peer_id, slot_index)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
||||||
|
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _slot_assigned(peer_id: int, slot_index: int) -> void:
|
||||||
|
slot_assigned_received.emit(peer_id, slot_index)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||||
|
func _request_match_config() -> void:
|
||||||
|
if not multiplayer.is_server() or _last_match_config.is_empty():
|
||||||
|
return
|
||||||
|
var peer_id := multiplayer.get_remote_sender_id()
|
||||||
|
_match_config.rpc_id(
|
||||||
|
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
|
||||||
|
_last_match_config["teams"], _last_match_config["spawn_indices"]
|
||||||
|
)
|
||||||
|
match_config_requested.emit(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
|
||||||
|
func _recv_input(bytes: PackedByteArray) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
_track_received(bytes.size())
|
||||||
|
var peer_id := multiplayer.get_remote_sender_id()
|
||||||
|
|
||||||
|
var state: _PeerInputState = _peer_input_state.get(peer_id)
|
||||||
|
if state == null:
|
||||||
|
state = _PeerInputState.new()
|
||||||
|
_peer_input_state[peer_id] = state
|
||||||
|
|
||||||
|
# Rolling 1s window (§3.1 step 2). Rolled over lazily on the first
|
||||||
|
# packet past the window boundary, not on a timer — this RPC only ever
|
||||||
|
# runs when a packet actually arrives, so there's nothing to roll over
|
||||||
|
# when nothing is arriving anyway.
|
||||||
|
var now_ms := Time.get_ticks_msec()
|
||||||
|
if now_ms - state.window_start_ms >= RATE_LIMIT_WINDOW_MS:
|
||||||
|
# The leaky bucket drains against the SAME budget the window itself was
|
||||||
|
# policed with, grace included — otherwise a server stall would still
|
||||||
|
# accumulate excess toward a disconnect for traffic the server just
|
||||||
|
# explicitly allowed.
|
||||||
|
state.excess_packets = maxf(0.0, state.excess_packets + float(state.packets_this_window) - float(_packet_budget(state)))
|
||||||
|
state.excess_bytes = maxf(0.0, state.excess_bytes + float(state.bytes_this_window) - float(_byte_budget(state)))
|
||||||
|
state.window_start_ms = now_ms
|
||||||
|
state.packets_this_window = 0
|
||||||
|
state.bytes_this_window = 0
|
||||||
|
state.rejects_recorded_this_window = 0
|
||||||
|
state.logged_rate_limit_this_window = false
|
||||||
|
if state.grace_windows_left > 0:
|
||||||
|
state.grace_windows_left -= 1
|
||||||
|
if state.grace_windows_left == 0:
|
||||||
|
state.grace_packets = 0
|
||||||
|
if state.excess_packets > RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT or state.excess_bytes > RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT:
|
||||||
|
# Record before disconnecting, same reasoning as _count_malformed:
|
||||||
|
# the log should contain the packet that ended the connection, not
|
||||||
|
# stop one short of it.
|
||||||
|
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
|
||||||
|
_disconnect_abusive_peer(peer_id, "input rate limit exceeded (excess_packets=%.0f excess_bytes=%.0f)" % [state.excess_packets, state.excess_bytes])
|
||||||
|
return
|
||||||
|
|
||||||
|
state.packets_this_window += 1
|
||||||
|
state.bytes_this_window += bytes.size()
|
||||||
|
if state.packets_this_window > _packet_budget(state) or state.bytes_this_window > _byte_budget(state):
|
||||||
|
# Over budget for the current window — drop, counted above at the next
|
||||||
|
# window roll.
|
||||||
|
if not state.logged_rate_limit_this_window:
|
||||||
|
# ONCE per window, not per packet: a flood is thousands of packets a
|
||||||
|
# second and the log line must not become the amplifier the replay
|
||||||
|
# recorder was capped to avoid being.
|
||||||
|
state.logged_rate_limit_this_window = true
|
||||||
|
ServerLog.warn("rate_limited", {
|
||||||
|
"peer_id": peer_id, "packets": state.packets_this_window,
|
||||||
|
"budget": _packet_budget(state), "grace": state.grace_packets,
|
||||||
|
})
|
||||||
|
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Framing (§3.1 step 3), validated before decoding — unpack_input can't
|
||||||
|
# be trusted to catch this itself: StreamPeerBuffer silently zero-fills
|
||||||
|
# past EOF rather than erroring (found during Phase 2's adversarial
|
||||||
|
# review's hostile-client stress test), so a too-short or size-mismatched
|
||||||
|
# payload would otherwise decode "successfully" into garbage actions
|
||||||
|
# instead of being rejected.
|
||||||
|
if bytes.size() < NetCodec.INPUT_HEADER_SIZE:
|
||||||
|
_count_malformed(peer_id, state, bytes)
|
||||||
|
return
|
||||||
|
var count: int = bytes[5] # type_version(1) + seq(4) precede count — see pack_input's own layout
|
||||||
|
if count == 0 or count > NetCodec.MAX_REDUNDANCY or bytes.size() != NetCodec.INPUT_HEADER_SIZE + count * NetCodec.INPUT_ENTRY_SIZE:
|
||||||
|
_count_malformed(peer_id, state, bytes)
|
||||||
|
return
|
||||||
|
|
||||||
|
var decoded := NetCodec.unpack_input(bytes)
|
||||||
|
# Carry the verbatim wire bytes alongside the decode. Task 5.10's replay
|
||||||
|
# log stores exactly what arrived rather than a re-serialisation, which is
|
||||||
|
# the whole reason it can reproduce a reported snap: a re-encode would
|
||||||
|
# launder away precisely the malformed or edge-case payload being chased.
|
||||||
|
decoded["raw"] = bytes
|
||||||
|
input_received.emit(peer_id, decoded)
|
||||||
|
|
||||||
|
|
||||||
|
# The budget a peer is actually policed against right now: the standing limit
|
||||||
|
# plus any outstanding server-stall grace. Bytes scale with packets by the same
|
||||||
|
# worst-case-packet factor RATE_LIMIT_BYTES_PER_SEC itself is derived from, so
|
||||||
|
# the two budgets can never drift apart by hand.
|
||||||
|
func _packet_budget(state: _PeerInputState) -> int:
|
||||||
|
return RATE_LIMIT_PACKETS_PER_SEC + state.grace_packets
|
||||||
|
|
||||||
|
|
||||||
|
func _byte_budget(state: _PeerInputState) -> int:
|
||||||
|
return _packet_budget(state) * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
|
||||||
|
|
||||||
|
|
||||||
|
func _count_malformed(peer_id: int, state: _PeerInputState, bytes: PackedByteArray) -> void:
|
||||||
|
state.malformed_count += 1
|
||||||
|
# Emitted before the disconnect check so the packet that finally crossed
|
||||||
|
# the limit is itself in the log, not just the 19 before it.
|
||||||
|
_emit_reject(peer_id, state, InputRejectReason.MALFORMED, bytes)
|
||||||
|
if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT:
|
||||||
|
_disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count)
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_reject(peer_id: int, state: _PeerInputState, reason: int, bytes: PackedByteArray) -> void:
|
||||||
|
var totals: Dictionary = _reject_totals.get(peer_id, {"malformed": 0, "rate_limit": 0})
|
||||||
|
var key := "rate_limit" if reason == InputRejectReason.RATE_LIMIT else "malformed"
|
||||||
|
totals[key] = int(totals[key]) + 1
|
||||||
|
_reject_totals[peer_id] = totals
|
||||||
|
if state.rejects_recorded_this_window >= REJECTS_RECORDED_PER_WINDOW:
|
||||||
|
return
|
||||||
|
state.rejects_recorded_this_window += 1
|
||||||
|
input_rejected.emit(peer_id, reason, bytes)
|
||||||
|
|
||||||
|
|
||||||
|
# Server-side, diagnostic. peer_id -> {"malformed": int, "rate_limit": int},
|
||||||
|
# uncapped and surviving the peer's disconnect. Peers with no rejects at all
|
||||||
|
# never appear, so an empty dictionary means a clean session.
|
||||||
|
func get_reject_totals() -> Dictionary:
|
||||||
|
return _reject_totals.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
|
||||||
|
push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason])
|
||||||
|
# Task 6.4: the one server event an operator is most likely to be asked
|
||||||
|
# about ("why was I kicked?"), and it was previously only a push_warning —
|
||||||
|
# which does not carry the peer, the reason or a timestamp into the log
|
||||||
|
# stream a container actually captures.
|
||||||
|
ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason})
|
||||||
|
NetworkManager.invalidate_peer(peer_id)
|
||||||
|
_peer_input_state.erase(peer_id)
|
||||||
|
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||||
|
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "unreliable_ordered", 2)
|
||||||
|
func _snapshot(bytes: PackedByteArray) -> void:
|
||||||
|
_track_received(bytes.size())
|
||||||
|
var decoded := NetCodec.unpack_snapshot(bytes)
|
||||||
|
snapshot_received.emit(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _state_change(state: int, at_tick: int) -> void:
|
||||||
|
# "authority" already means a forging client is rejected by Godot itself
|
||||||
|
# (verified for _match_config/_score_update/_snapshot during Phase 2), but
|
||||||
|
# an authoritative server sending a state this build doesn't know about is
|
||||||
|
# a real forward-compatibility case — drop it rather than driving the
|
||||||
|
# client into an undefined state.
|
||||||
|
if not MatchState.is_valid(state):
|
||||||
|
push_warning("MatchSim: ignoring unknown match_state %d from server" % state)
|
||||||
|
return
|
||||||
|
state_change_received.emit(state, at_tick)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
|
||||||
|
# 4 quaternion floats per body. A mismatch means a corrupt or hostile
|
||||||
|
# payload; dropping it is safe because the snapshot stream still carries
|
||||||
|
# authoritative poses and the next kickoff will re-sync.
|
||||||
|
if rotations.size() != positions.size() * 4:
|
||||||
|
push_warning("MatchSim: kickoff payload mismatch (%d positions, %d rotation floats)" % [positions.size(), rotations.size()])
|
||||||
|
return
|
||||||
|
kickoff_received.emit(positions, rotations, countdown_start_tick, reset_gen)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
|
||||||
|
goal_scored_received.emit(scoring_team, score, goal_tick, resume_tick)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
|
||||||
|
clock_state_received.emit(running, end_tick, remaining_ticks, at_tick)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
|
||||||
|
if not MatchState.is_valid(state):
|
||||||
|
push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state)
|
||||||
|
return
|
||||||
|
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 0)
|
||||||
|
func _score_update(score: Dictionary) -> void:
|
||||||
|
score_update_received.emit(score)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bk81de78uwut
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
class_name MatchState
|
||||||
|
|
||||||
|
# Match lifecycle states (MULTIPLAYER_SPEC.md §6.1; multiplayer-next.md task 5.1).
|
||||||
|
#
|
||||||
|
# Pure data + a transition table, deliberately with no scene, RPC or
|
||||||
|
# NetworkedMatch dependency — same reason net_codec.gd and
|
||||||
|
# input_jitter_buffer.gd are standalone: the table can then be exhaustively
|
||||||
|
# unit-tested without a live match.
|
||||||
|
#
|
||||||
|
# The integer values ARE the wire format. `match_state` has been a u8 in the
|
||||||
|
# snapshot header since §2.4 (net_codec.gd's pack_snapshot_body_segment), so
|
||||||
|
# these numbers are protocol, not an implementation detail: never renumber an
|
||||||
|
# existing state, only append. LOBBY is 0 so a zeroed/placeholder snapshot
|
||||||
|
# body decodes to a state that is obviously "not in a match" rather than to
|
||||||
|
# something mid-play.
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
LOBBY = 0,
|
||||||
|
LOADING = 1,
|
||||||
|
WARMUP = 2,
|
||||||
|
PLAYING = 3,
|
||||||
|
GOAL_PAUSE = 4,
|
||||||
|
FULL_TIME = 5,
|
||||||
|
OVERTIME_WARMUP = 6,
|
||||||
|
OVERTIME = 7,
|
||||||
|
RESULTS = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legal successors, straight from §6.1's diagram. Enforced rather than
|
||||||
|
# documented: an illegal transition is a server logic bug, and the failure it
|
||||||
|
# otherwise produces (clients following the server into a state its own code
|
||||||
|
# never expected to broadcast) is exactly the kind that shows up as an
|
||||||
|
# unreproducible field report three phases later.
|
||||||
|
#
|
||||||
|
# LOBBY is reachable from ANY state and is handled separately in
|
||||||
|
# can_transition() rather than being listed nine times — §6.4's "if the last
|
||||||
|
# human leaves, abort to LOBBY" can fire at any point, including mid-goal.
|
||||||
|
const _SUCCESSORS := {
|
||||||
|
State.LOBBY: [State.LOADING],
|
||||||
|
State.LOADING: [State.WARMUP],
|
||||||
|
State.WARMUP: [State.PLAYING],
|
||||||
|
# A goal, or the clock running out. FULL_TIME is entered on the clock even
|
||||||
|
# if a goal is in flight — §6.2 step 9's clock is authoritative.
|
||||||
|
State.PLAYING: [State.GOAL_PAUSE, State.FULL_TIME],
|
||||||
|
# Back to a kickoff, or straight to results when the goal that caused the
|
||||||
|
# pause also ended the match (golden goal in overtime, or a goal on the
|
||||||
|
# final tick).
|
||||||
|
State.GOAL_PAUSE: [State.WARMUP, State.OVERTIME_WARMUP, State.RESULTS],
|
||||||
|
State.FULL_TIME: [State.OVERTIME_WARMUP, State.RESULTS],
|
||||||
|
State.OVERTIME_WARMUP: [State.OVERTIME],
|
||||||
|
State.OVERTIME: [State.GOAL_PAUSE, State.RESULTS],
|
||||||
|
State.RESULTS: [State.LOBBY],
|
||||||
|
}
|
||||||
|
|
||||||
|
# States in which the simulation is live and inputs drive ships. Everything
|
||||||
|
# else freezes bodies (§6.2 steps 6 and 8). Kept as a set here rather than as
|
||||||
|
# an `if state == PLAYING or state == OVERTIME` scattered through
|
||||||
|
# NetworkedMatch, so adding a future live state can't miss a site.
|
||||||
|
const _LIVE := [State.PLAYING, State.OVERTIME]
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid(state: int) -> bool:
|
||||||
|
return state in State.values()
|
||||||
|
|
||||||
|
|
||||||
|
static func is_live(state: int) -> bool:
|
||||||
|
return state in _LIVE
|
||||||
|
|
||||||
|
|
||||||
|
# True when the match is over and the clock should not advance. Distinct from
|
||||||
|
# `not is_live()`: a WARMUP is not live but the match is very much ongoing.
|
||||||
|
static func is_terminal(state: int) -> bool:
|
||||||
|
return state == State.RESULTS or state == State.LOBBY
|
||||||
|
|
||||||
|
|
||||||
|
static func can_transition(from_state: int, to_state: int) -> bool:
|
||||||
|
if not is_valid(from_state) or not is_valid(to_state):
|
||||||
|
return false
|
||||||
|
if to_state == State.LOBBY:
|
||||||
|
return from_state != State.LOBBY # §6.4 abort, from anywhere
|
||||||
|
return to_state in _SUCCESSORS.get(from_state, [])
|
||||||
|
|
||||||
|
|
||||||
|
static func to_name(state: int) -> String:
|
||||||
|
for key in State.keys():
|
||||||
|
if State[key] == state:
|
||||||
|
return key
|
||||||
|
return "UNKNOWN(%d)" % state
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b1etnxbdelq1p
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
const CLIENT_BUILD := "dev"
|
||||||
|
const PROTOCOL_VERSION := 1
|
||||||
|
const HEARTBEAT_SECONDS := 10.0
|
||||||
|
const RECOVERY_POLL_SECONDS := 2.0
|
||||||
|
|
||||||
|
@onready var playlist_dropdown: OptionButton = %PlaylistDropdown
|
||||||
|
@onready var status_label: Label = %StatusLabel
|
||||||
|
@onready var detail_label: Label = %DetailLabel
|
||||||
|
@onready var ranked_profile_label: Label = %RankedProfileLabel
|
||||||
|
@onready var queue_button: Button = %QueueButton
|
||||||
|
@onready var cancel_button: Button = %CancelButton
|
||||||
|
@onready var accept_button: Button = %AcceptButton
|
||||||
|
@onready var decline_button: Button = %DeclineButton
|
||||||
|
@onready var back_button: Button = %BackButton
|
||||||
|
|
||||||
|
var _elapsed_seconds := 0.0
|
||||||
|
var _heartbeat_seconds := 0.0
|
||||||
|
var _recovery_poll_seconds := 0.0
|
||||||
|
# Regions still awaiting RTT evidence, and the queue request deferred until at
|
||||||
|
# least one lands. The matcher ignores a ticket with no predicted RTT, so
|
||||||
|
# queueing before probing produces a search that can never match.
|
||||||
|
var _pending_probe_regions: Array[String] = []
|
||||||
|
var _probed_regions: Array[String] = []
|
||||||
|
var _deferred_queue := {}
|
||||||
|
var _web_api_ticket_handle := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
AudioManager.bind_tree_buttons(self)
|
||||||
|
playlist_dropdown.add_item("Casual")
|
||||||
|
playlist_dropdown.set_item_metadata(0, "casual")
|
||||||
|
playlist_dropdown.add_item("Ranked")
|
||||||
|
playlist_dropdown.set_item_metadata(1, "ranked")
|
||||||
|
playlist_dropdown.item_selected.connect(_on_playlist_selected)
|
||||||
|
ControlPlaneClient.state.changed.connect(_on_state_changed)
|
||||||
|
ControlPlaneClient.request_failed.connect(_on_request_failed)
|
||||||
|
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
|
||||||
|
ControlPlaneClient.session_expired.connect(_on_session_expired)
|
||||||
|
ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received)
|
||||||
|
ControlPlaneClient.probe_recorded.connect(_on_probe_recorded)
|
||||||
|
_ensure_signed_in()
|
||||||
|
_refresh_ranked_profile()
|
||||||
|
_render(ControlPlaneClient.state.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
# Matchmaking previously opened with an empty token against a loopback default,
|
||||||
|
# so every request failed ERR_UNAUTHORIZED before reaching the network. Point
|
||||||
|
# the client at its configured endpoint and complete Steam sign-in first.
|
||||||
|
func _ensure_signed_in() -> void:
|
||||||
|
if ControlPlaneClient.has_session():
|
||||||
|
return
|
||||||
|
if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""):
|
||||||
|
_on_local_error("Matchmaking endpoint is not configured")
|
||||||
|
return
|
||||||
|
if not SteamBootstrap.supports_web_api_ticket():
|
||||||
|
# Deliberately explicit rather than silently presenting a search that
|
||||||
|
# can never start: online matchmaking requires a verified identity.
|
||||||
|
_on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason())
|
||||||
|
return
|
||||||
|
var steam := Engine.get_singleton("Steam")
|
||||||
|
if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket):
|
||||||
|
steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket)
|
||||||
|
_web_api_ticket_handle = SteamBootstrap.request_web_api_ticket()
|
||||||
|
if _web_api_ticket_handle <= 0:
|
||||||
|
_on_local_error("Could not request a Steam authentication ticket")
|
||||||
|
return
|
||||||
|
ControlPlaneClient.state.set_notice("Signing in...")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void:
|
||||||
|
# Steam reports k_EResultOK as 1; anything else means no usable ticket.
|
||||||
|
if result != 1 or ticket.is_empty():
|
||||||
|
_on_local_error("Steam declined to issue an authentication ticket")
|
||||||
|
return
|
||||||
|
var encoded := SteamBootstrap.encode_web_api_ticket(ticket)
|
||||||
|
if encoded.is_empty():
|
||||||
|
_on_local_error("Steam returned an unusable authentication ticket")
|
||||||
|
return
|
||||||
|
var err := ControlPlaneClient.login_steam(encoded)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Could not sign in: %s" % error_string(err))
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
# The ticket handle is a Steam resource; releasing it avoids leaking one
|
||||||
|
# per visit to this screen.
|
||||||
|
if _web_api_ticket_handle > 0:
|
||||||
|
SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle)
|
||||||
|
_web_api_ticket_handle = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
|
||||||
|
_elapsed_seconds += delta
|
||||||
|
_heartbeat_seconds += delta
|
||||||
|
_recovery_poll_seconds += delta
|
||||||
|
if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS:
|
||||||
|
_recovery_poll_seconds = 0.0
|
||||||
|
var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id)
|
||||||
|
if recovery_err != OK and recovery_err != ERR_BUSY:
|
||||||
|
_on_local_error("State recovery unavailable: %s" % error_string(recovery_err))
|
||||||
|
if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS:
|
||||||
|
_heartbeat_seconds = 0.0
|
||||||
|
var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Heartbeat unavailable: %s" % error_string(err))
|
||||||
|
_render(ControlPlaneClient.state.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_queue_pressed() -> void:
|
||||||
|
if not ControlPlaneClient.has_session():
|
||||||
|
# Queueing without a session would fail at the first request guard.
|
||||||
|
_ensure_signed_in()
|
||||||
|
return
|
||||||
|
if ControlPlaneClient.can_retry_queue_create():
|
||||||
|
var retry_err := ControlPlaneClient.retry_queue_create()
|
||||||
|
if retry_err != OK:
|
||||||
|
_on_local_error("Could not retry matchmaking: %s" % error_string(retry_err))
|
||||||
|
return
|
||||||
|
if ControlPlaneClient.can_retry_last_mutation():
|
||||||
|
var mutation_err := ControlPlaneClient.retry_last_mutation()
|
||||||
|
if mutation_err != OK:
|
||||||
|
_on_local_error("Could not retry matchmaking action: %s" % error_string(mutation_err))
|
||||||
|
return
|
||||||
|
if not _can_start_new_search(ControlPlaneClient.state.phase):
|
||||||
|
return
|
||||||
|
_elapsed_seconds = 0.0
|
||||||
|
_heartbeat_seconds = 0.0
|
||||||
|
_recovery_poll_seconds = 0.0
|
||||||
|
var playlist := String(playlist_dropdown.get_selected_metadata())
|
||||||
|
var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())]
|
||||||
|
# A ticket with no regional RTT evidence is invisible to the matcher, so
|
||||||
|
# collect it first and queue once the first region reports.
|
||||||
|
if _probed_regions.is_empty():
|
||||||
|
_deferred_queue = {"ticket_id": ticket_id, "playlist": playlist}
|
||||||
|
_start_probe_collection()
|
||||||
|
return
|
||||||
|
var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Could not start matchmaking: %s" % error_string(err))
|
||||||
|
|
||||||
|
|
||||||
|
func _start_probe_collection() -> void:
|
||||||
|
_pending_probe_regions = []
|
||||||
|
for region in ControlPlaneClient.PROBE_REGIONS:
|
||||||
|
_pending_probe_regions.append(String(region))
|
||||||
|
ControlPlaneClient.state.set_notice("Measuring connection quality...")
|
||||||
|
_request_next_probe()
|
||||||
|
|
||||||
|
|
||||||
|
# One request at a time: the client serialises HTTP through a single
|
||||||
|
# HTTPRequest, so a second call would return ERR_BUSY.
|
||||||
|
func _request_next_probe() -> void:
|
||||||
|
if _pending_probe_regions.is_empty():
|
||||||
|
_finish_probe_collection()
|
||||||
|
return
|
||||||
|
var region := _pending_probe_regions[0]
|
||||||
|
var err := ControlPlaneClient.request_probe_challenge(region)
|
||||||
|
if err != OK and err != ERR_BUSY:
|
||||||
|
# A region we cannot probe is not fatal; placement just uses the
|
||||||
|
# regions that did respond.
|
||||||
|
_pending_probe_regions.remove_at(0)
|
||||||
|
_request_next_probe()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_probe_challenge_received(region: String, nonce_base64: String) -> void:
|
||||||
|
var err := ControlPlaneClient.submit_probe_answer(region, nonce_base64, ControlPlaneClient.opaque_location_payload())
|
||||||
|
if err != OK:
|
||||||
|
_drop_pending_probe(region)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_probe_recorded(region: String, _server_rtt_ms: int) -> void:
|
||||||
|
if not _probed_regions.has(region):
|
||||||
|
_probed_regions.append(region)
|
||||||
|
_drop_pending_probe(region)
|
||||||
|
|
||||||
|
|
||||||
|
func _drop_pending_probe(region: String) -> void:
|
||||||
|
var index := _pending_probe_regions.find(region)
|
||||||
|
if index >= 0:
|
||||||
|
_pending_probe_regions.remove_at(index)
|
||||||
|
_request_next_probe()
|
||||||
|
|
||||||
|
|
||||||
|
func _finish_probe_collection() -> void:
|
||||||
|
if _deferred_queue.is_empty():
|
||||||
|
return
|
||||||
|
var queued := _deferred_queue
|
||||||
|
_deferred_queue = {}
|
||||||
|
if _probed_regions.is_empty():
|
||||||
|
# Queueing now would create a ticket the matcher can never select.
|
||||||
|
_on_local_error("Could not measure connection quality to any region; matchmaking is unavailable")
|
||||||
|
return
|
||||||
|
var err := ControlPlaneClient.queue_create(String(queued["ticket_id"]), String(queued["playlist"]), CLIENT_BUILD, PROTOCOL_VERSION)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Could not start matchmaking: %s" % error_string(err))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_cancel_pressed() -> void:
|
||||||
|
if not ControlPlaneClient.state.can_cancel():
|
||||||
|
return
|
||||||
|
var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Could not cancel matchmaking: %s" % error_string(err))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_accept_pressed() -> void:
|
||||||
|
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Could not accept proposal: %s" % error_string(err))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_decline_pressed() -> void:
|
||||||
|
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision)
|
||||||
|
if err != OK:
|
||||||
|
_on_local_error("Could not decline proposal: %s" % error_string(err))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_playlist_selected(_index: int) -> void:
|
||||||
|
_refresh_ranked_profile()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_ranked_profile() -> void:
|
||||||
|
var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked"
|
||||||
|
ranked_profile_label.visible = ranked
|
||||||
|
if not ranked:
|
||||||
|
return
|
||||||
|
var err := ControlPlaneClient.fetch_ranked_profile()
|
||||||
|
if err != OK and err != ERR_BUSY:
|
||||||
|
ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_back_pressed() -> void:
|
||||||
|
if ControlPlaneClient.state.can_cancel():
|
||||||
|
status_label.text = "Cancel the active search before leaving"
|
||||||
|
return
|
||||||
|
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_state_changed(snapshot: Dictionary) -> void:
|
||||||
|
_render(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void:
|
||||||
|
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
|
||||||
|
_render(ControlPlaneClient.state.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void:
|
||||||
|
detail_label.text = detail
|
||||||
|
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
|
||||||
|
_render(ControlPlaneClient.state.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_session_expired() -> void:
|
||||||
|
status_label.text = "Session expired"
|
||||||
|
detail_label.text = "Sign in again before searching for a match"
|
||||||
|
queue_button.disabled = true
|
||||||
|
|
||||||
|
|
||||||
|
func _on_local_error(detail: String) -> void:
|
||||||
|
detail_label.text = detail
|
||||||
|
|
||||||
|
|
||||||
|
static func phase_label(phase: String) -> String:
|
||||||
|
match phase:
|
||||||
|
MatchmakingState.IDLE:
|
||||||
|
return "Ready to search"
|
||||||
|
MatchmakingState.QUEUED:
|
||||||
|
return "Searching for players"
|
||||||
|
MatchmakingState.PROPOSED:
|
||||||
|
return "Match found — confirm"
|
||||||
|
MatchmakingState.ACCEPTED:
|
||||||
|
return "Match accepted — preparing server"
|
||||||
|
MatchmakingState.ALLOCATING:
|
||||||
|
return "Preparing match server"
|
||||||
|
MatchmakingState.PROCESS_READY:
|
||||||
|
return "Match server started"
|
||||||
|
MatchmakingState.ASSIGNMENT_READY:
|
||||||
|
return "Match assigned"
|
||||||
|
MatchmakingState.CONNECTING:
|
||||||
|
return "Connecting to match"
|
||||||
|
MatchmakingState.LIVE:
|
||||||
|
return "Match in progress"
|
||||||
|
MatchmakingState.RESULT_PENDING:
|
||||||
|
return "Recording match result"
|
||||||
|
MatchmakingState.COMPLETED:
|
||||||
|
return "Match complete"
|
||||||
|
MatchmakingState.ASSIGNED:
|
||||||
|
return "Match assigned"
|
||||||
|
MatchmakingState.CANCELLED:
|
||||||
|
return "Search cancelled"
|
||||||
|
MatchmakingState.EXPIRED:
|
||||||
|
return "Search expired"
|
||||||
|
MatchmakingState.FAILED:
|
||||||
|
return "Matchmaking unavailable"
|
||||||
|
_:
|
||||||
|
return "Recovering matchmaking state"
|
||||||
|
|
||||||
|
|
||||||
|
func _render(snapshot: Dictionary) -> void:
|
||||||
|
var phase := String(snapshot.get("phase", MatchmakingState.IDLE))
|
||||||
|
status_label.text = phase_label(phase)
|
||||||
|
if String(snapshot.get("message", "")) != "":
|
||||||
|
detail_label.text = String(snapshot["message"])
|
||||||
|
elif phase == MatchmakingState.QUEUED:
|
||||||
|
var waited := _elapsed_seconds
|
||||||
|
if int(snapshot.get("enqueued_at_unix", 0)) > 0:
|
||||||
|
waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system())))
|
||||||
|
detail_label.text = queue_wait_detail_text(int(waited), int(snapshot.get("revision", 0)))
|
||||||
|
elif phase == MatchmakingState.PROPOSED:
|
||||||
|
detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system()))
|
||||||
|
elif phase == MatchmakingState.ACCEPTED:
|
||||||
|
detail_label.text = phase_detail_label(phase)
|
||||||
|
elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED]:
|
||||||
|
detail_label.text = phase_detail_label(phase)
|
||||||
|
elif phase in [MatchmakingState.CONNECTING, MatchmakingState.LIVE]:
|
||||||
|
detail_label.text = "%s · %s" % [phase_detail_label(phase), latency_detail_text(NetworkManager.rtt_ms)]
|
||||||
|
elif phase == MatchmakingState.RESULT_PENDING:
|
||||||
|
detail_label.text = "The server is confirming the final result"
|
||||||
|
elif phase == MatchmakingState.COMPLETED:
|
||||||
|
detail_label.text = "The match result has been recorded"
|
||||||
|
elif phase == MatchmakingState.IDLE:
|
||||||
|
detail_label.text = "Choose a playlist to begin"
|
||||||
|
cancel_button.visible = ControlPlaneClient.state.can_cancel()
|
||||||
|
accept_button.visible = phase == MatchmakingState.PROPOSED
|
||||||
|
decline_button.visible = phase == MatchmakingState.PROPOSED
|
||||||
|
var retry_search := ControlPlaneClient.can_retry_queue_create()
|
||||||
|
var retry_mutation := ControlPlaneClient.can_retry_last_mutation()
|
||||||
|
queue_button.disabled = ControlPlaneClient.auth_expired or not (_can_start_new_search(phase) or retry_search or retry_mutation)
|
||||||
|
queue_button.text = "Retry Search" if retry_search else ("Retry Request" if retry_mutation else "Search")
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_terminal(phase: String) -> bool:
|
||||||
|
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]
|
||||||
|
|
||||||
|
|
||||||
|
static func phase_detail_label(phase: String) -> String:
|
||||||
|
match phase:
|
||||||
|
MatchmakingState.ACCEPTED:
|
||||||
|
return "All players accepted; preparing the match server"
|
||||||
|
MatchmakingState.ALLOCATING:
|
||||||
|
return "Finding a dedicated match server"
|
||||||
|
MatchmakingState.PROCESS_READY:
|
||||||
|
return "Match server started; preparing player assignments"
|
||||||
|
MatchmakingState.ASSIGNMENT_READY:
|
||||||
|
return "Player assignments are ready"
|
||||||
|
MatchmakingState.ASSIGNED:
|
||||||
|
return "Your match server is ready"
|
||||||
|
MatchmakingState.CONNECTING:
|
||||||
|
return "Connecting to the match server"
|
||||||
|
MatchmakingState.LIVE:
|
||||||
|
return "Match in progress"
|
||||||
|
_:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> String:
|
||||||
|
if expires_at_unix <= 0:
|
||||||
|
return "Review the proposal before the countdown expires"
|
||||||
|
return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix)
|
||||||
|
|
||||||
|
|
||||||
|
static func queue_wait_detail_text(waited_seconds: int, revision: int) -> String:
|
||||||
|
var waited := maxi(0, waited_seconds)
|
||||||
|
var suffix := "looking for compatible players"
|
||||||
|
if waited >= 30:
|
||||||
|
suffix = "widening skill range while keeping latency limits"
|
||||||
|
elif waited >= 10:
|
||||||
|
suffix = "matching nearby skill and latency"
|
||||||
|
return "Waiting %ds · %s · revision %d" % [waited, suffix, maxi(0, revision)]
|
||||||
|
|
||||||
|
|
||||||
|
static func latency_detail_text(rtt_ms: float) -> String:
|
||||||
|
if not is_finite(rtt_ms) or rtt_ms < 0.0:
|
||||||
|
return "Latency: measuring"
|
||||||
|
var rounded := int(round(rtt_ms))
|
||||||
|
if rtt_ms <= 50.0:
|
||||||
|
return "Latency: %dms · excellent" % rounded
|
||||||
|
if rtt_ms <= 100.0:
|
||||||
|
return "Latency: %dms · good" % rounded
|
||||||
|
return "Latency: %dms · high" % rounded
|
||||||
|
|
||||||
|
|
||||||
|
static func _can_start_new_search(phase: String) -> bool:
|
||||||
|
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
class_name MatchmakingState
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Client-side projection of the authenticated control-plane lifecycle. The
|
||||||
|
# server remains authoritative; this object only decides what the UI may show
|
||||||
|
# and refuses stale, gapped, or conflicting revisions instead of guessing.
|
||||||
|
|
||||||
|
signal changed(snapshot: Dictionary)
|
||||||
|
signal resync_required(resource_id: String)
|
||||||
|
|
||||||
|
const IDLE := "IDLE"
|
||||||
|
const QUEUED := "QUEUED"
|
||||||
|
const PROPOSED := "PROPOSED"
|
||||||
|
const ACCEPTED := "ACCEPTED"
|
||||||
|
const ALLOCATING := "ALLOCATING"
|
||||||
|
const PROCESS_READY := "PROCESS_READY"
|
||||||
|
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
|
||||||
|
const CONNECTING := "CONNECTING"
|
||||||
|
const LIVE := "LIVE"
|
||||||
|
const ASSIGNED := "ASSIGNED"
|
||||||
|
const RESULT_PENDING := "RESULT_PENDING"
|
||||||
|
const COMPLETED := "COMPLETED"
|
||||||
|
const CANCELLED := "CANCELLED"
|
||||||
|
const EXPIRED := "EXPIRED"
|
||||||
|
const FAILED := "FAILED"
|
||||||
|
|
||||||
|
var phase := IDLE
|
||||||
|
var ticket_id := ""
|
||||||
|
var playlist := ""
|
||||||
|
var revision := 0
|
||||||
|
var enqueued_at_unix := 0
|
||||||
|
var expires_at_unix := 0
|
||||||
|
var proposal_id := ""
|
||||||
|
var proposal_revision := 0
|
||||||
|
var proposal_state := ""
|
||||||
|
var message := ""
|
||||||
|
var needs_resync := false
|
||||||
|
|
||||||
|
|
||||||
|
func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
|
||||||
|
if new_ticket_id.is_empty() or (new_playlist != "casual" and new_playlist != "ranked"):
|
||||||
|
return false
|
||||||
|
_reset()
|
||||||
|
ticket_id = new_ticket_id
|
||||||
|
playlist = new_playlist
|
||||||
|
phase = QUEUED
|
||||||
|
_emit_changed()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func apply_ticket_update(update: Dictionary, authoritative_snapshot: bool = false) -> bool:
|
||||||
|
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
if update.has("playlist") and not _valid_playlist(String(update["playlist"])):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
if update.has("enqueued_at_unix") and not _valid_epoch(update["enqueued_at_unix"]):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
var incoming_revision := int(update["revision"])
|
||||||
|
if incoming_revision < revision:
|
||||||
|
return false
|
||||||
|
if incoming_revision == revision:
|
||||||
|
if _ticket_differs(update):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
# expires_at_unix is deliberately not part of _ticket_differs' conflict
|
||||||
|
# check (see its own comment) but is still adopted here: begin_queue()
|
||||||
|
# has no way to know the server-assigned expiry in advance, so the
|
||||||
|
# very first same-revision confirmation is the only place a freshly
|
||||||
|
# queued ticket's expiry is ever set at all.
|
||||||
|
if update.has("expires_at_unix"):
|
||||||
|
expires_at_unix = int(update["expires_at_unix"])
|
||||||
|
if update.has("enqueued_at_unix"):
|
||||||
|
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||||
|
return true
|
||||||
|
if incoming_revision > revision + 1 and not authoritative_snapshot:
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
var incoming_state := String(update["state"])
|
||||||
|
if not _is_ticket_state(incoming_state):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
if authoritative_snapshot:
|
||||||
|
if not _can_reach_ticket_state(phase, incoming_state):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
elif not _is_legal_ticket_transition(phase, incoming_state):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
|
revision = incoming_revision
|
||||||
|
phase = incoming_state
|
||||||
|
if update.has("playlist"):
|
||||||
|
playlist = String(update["playlist"])
|
||||||
|
if update.has("expires_at_unix"):
|
||||||
|
expires_at_unix = int(update["expires_at_unix"])
|
||||||
|
if update.has("enqueued_at_unix"):
|
||||||
|
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
|
||||||
|
if update.has("message"):
|
||||||
|
message = String(update["message"])
|
||||||
|
else:
|
||||||
|
message = ""
|
||||||
|
needs_resync = false
|
||||||
|
_emit_changed()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func apply_proposal_update(update: Dictionary) -> bool:
|
||||||
|
if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
var incoming_id := String(update["proposal_id"])
|
||||||
|
if proposal_id.is_empty():
|
||||||
|
proposal_id = incoming_id
|
||||||
|
elif proposal_id != incoming_id:
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
var incoming_revision := int(update["revision"])
|
||||||
|
if incoming_revision < proposal_revision:
|
||||||
|
return false
|
||||||
|
if incoming_revision == proposal_revision and not proposal_state.is_empty():
|
||||||
|
if String(update["state"]) != proposal_state:
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
return true
|
||||||
|
if not proposal_state.is_empty() and incoming_revision > proposal_revision + 1:
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
var incoming_proposal_state := String(update["state"])
|
||||||
|
if incoming_proposal_state == "OPEN":
|
||||||
|
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
phase = PROPOSED
|
||||||
|
elif incoming_proposal_state == "ACCEPTED":
|
||||||
|
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
phase = ALLOCATING
|
||||||
|
elif incoming_proposal_state == "DECLINED":
|
||||||
|
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
if phase == PROPOSED:
|
||||||
|
phase = QUEUED
|
||||||
|
message = "A player declined the match proposal"
|
||||||
|
elif incoming_proposal_state == "EXPIRED":
|
||||||
|
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
if phase == PROPOSED:
|
||||||
|
phase = QUEUED
|
||||||
|
message = "The match proposal expired"
|
||||||
|
elif incoming_proposal_state == "CANCELLED":
|
||||||
|
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
if phase == PROPOSED:
|
||||||
|
phase = QUEUED
|
||||||
|
message = "The match proposal was cancelled"
|
||||||
|
else:
|
||||||
|
return _request_resync(proposal_id)
|
||||||
|
proposal_revision = incoming_revision
|
||||||
|
proposal_state = incoming_proposal_state
|
||||||
|
needs_resync = false
|
||||||
|
if incoming_proposal_state == "OPEN" or incoming_proposal_state == "ACCEPTED":
|
||||||
|
message = ""
|
||||||
|
if update.has("expires_at_unix"):
|
||||||
|
expires_at_unix = int(update["expires_at_unix"])
|
||||||
|
_emit_changed()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func prepare_proposal_recovery(new_proposal_id: String) -> bool:
|
||||||
|
if not _valid_opaque_id(new_proposal_id):
|
||||||
|
return false
|
||||||
|
if proposal_id == new_proposal_id:
|
||||||
|
return true
|
||||||
|
if proposal_state not in ["", "DECLINED", "EXPIRED", "CANCELLED"]:
|
||||||
|
return false
|
||||||
|
proposal_id = new_proposal_id
|
||||||
|
proposal_revision = 0
|
||||||
|
proposal_state = ""
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func mark_assignment_ready() -> void:
|
||||||
|
phase = ASSIGNMENT_READY
|
||||||
|
message = "Match server is ready"
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func mark_connecting() -> void:
|
||||||
|
phase = CONNECTING
|
||||||
|
message = "Connecting to match server"
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func mark_live() -> void:
|
||||||
|
phase = LIVE
|
||||||
|
message = "Match in progress"
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func fail(reason: String) -> void:
|
||||||
|
phase = FAILED
|
||||||
|
message = reason if not reason.is_empty() else "Matchmaking failed"
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func expire(reason: String = "Matchmaking expired") -> void:
|
||||||
|
phase = EXPIRED
|
||||||
|
message = reason
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func set_notice(notice: String) -> void:
|
||||||
|
message = notice
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func restore_snapshot(saved: Dictionary) -> bool:
|
||||||
|
_reset()
|
||||||
|
if saved.is_empty():
|
||||||
|
return true
|
||||||
|
if saved.has("phase") and not saved["phase"] is String:
|
||||||
|
return false
|
||||||
|
if saved.has("ticket_id") and not saved["ticket_id"] is String:
|
||||||
|
return false
|
||||||
|
if saved.has("playlist") and not saved["playlist"] is String:
|
||||||
|
return false
|
||||||
|
if saved.has("proposal_id") and not saved["proposal_id"] is String:
|
||||||
|
return false
|
||||||
|
if saved.has("proposal_state") and not saved["proposal_state"] is String:
|
||||||
|
return false
|
||||||
|
if saved.has("message") and not saved["message"] is String:
|
||||||
|
return false
|
||||||
|
if saved.has("revision") and not _valid_revision(saved["revision"]):
|
||||||
|
return false
|
||||||
|
for epoch_key in ["enqueued_at_unix", "expires_at_unix"]:
|
||||||
|
if saved.has(epoch_key) and not _valid_epoch(saved[epoch_key]):
|
||||||
|
return false
|
||||||
|
if saved.has("proposal_revision") and not _valid_revision(saved["proposal_revision"]):
|
||||||
|
return false
|
||||||
|
var saved_phase := String(saved.get("phase", IDLE))
|
||||||
|
var saved_ticket_id := String(saved.get("ticket_id", ""))
|
||||||
|
if not _valid_opaque_id(saved_ticket_id) or not _is_ticket_state(saved_phase):
|
||||||
|
return false
|
||||||
|
var saved_playlist := String(saved.get("playlist", ""))
|
||||||
|
if saved_playlist != "casual" and saved_playlist != "ranked":
|
||||||
|
return false
|
||||||
|
var saved_proposal_id := String(saved.get("proposal_id", ""))
|
||||||
|
var saved_proposal_state := String(saved.get("proposal_state", ""))
|
||||||
|
if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and not _valid_opaque_id(saved_proposal_id)) or (saved_proposal_state.is_empty() and not saved_proposal_id.is_empty() and not _valid_opaque_id(saved_proposal_id)):
|
||||||
|
return false
|
||||||
|
ticket_id = saved_ticket_id
|
||||||
|
playlist = saved_playlist
|
||||||
|
phase = saved_phase
|
||||||
|
revision = maxi(0, int(saved.get("revision", 0)))
|
||||||
|
enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0)))
|
||||||
|
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
|
||||||
|
proposal_id = saved_proposal_id
|
||||||
|
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
|
||||||
|
proposal_state = saved_proposal_state
|
||||||
|
message = "Recovering authoritative matchmaking state"
|
||||||
|
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED
|
||||||
|
_emit_changed()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func can_cancel() -> bool:
|
||||||
|
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
|
||||||
|
|
||||||
|
|
||||||
|
func has_open_proposal() -> bool:
|
||||||
|
return not proposal_id.is_empty() and proposal_state == "OPEN"
|
||||||
|
|
||||||
|
|
||||||
|
func waited_seconds(now_unix: int) -> int:
|
||||||
|
if enqueued_at_unix <= 0:
|
||||||
|
return 0
|
||||||
|
return maxi(0, now_unix - enqueued_at_unix)
|
||||||
|
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
|
||||||
|
|
||||||
|
|
||||||
|
func _ticket_differs(update: Dictionary) -> bool:
|
||||||
|
# expires_at_unix is excluded on purpose: begin_queue()'s optimistic local
|
||||||
|
# state has no way to know the server-assigned expiry before the first
|
||||||
|
# real response arrives, so comparing it here made the very first
|
||||||
|
# same-revision confirmation after every begin_queue() look like a
|
||||||
|
# conflict, unconditionally -- found by an actual client hitting a real
|
||||||
|
# server: apply_ticket_update() kept requesting a resync, whose own
|
||||||
|
# response hit exactly the same false mismatch, forever, which
|
||||||
|
# control_plane_smoke.gd (a live end-to-end test, not a mock) surfaced as
|
||||||
|
# a request that legitimately never terminates. It's still kept current
|
||||||
|
# via the direct assignment below, just not treated as a conflict signal.
|
||||||
|
return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist)
|
||||||
|
|
||||||
|
|
||||||
|
func _request_resync(resource_id: String) -> bool:
|
||||||
|
needs_resync = true
|
||||||
|
resync_required.emit(resource_id)
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_changed() -> void:
|
||||||
|
changed.emit(snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
func _reset() -> void:
|
||||||
|
phase = IDLE
|
||||||
|
playlist = ""
|
||||||
|
revision = 0
|
||||||
|
enqueued_at_unix = 0
|
||||||
|
expires_at_unix = 0
|
||||||
|
proposal_id = ""
|
||||||
|
proposal_revision = 0
|
||||||
|
proposal_state = ""
|
||||||
|
message = ""
|
||||||
|
needs_resync = false
|
||||||
|
|
||||||
|
|
||||||
|
func _is_ticket_state(value: String) -> bool:
|
||||||
|
return value in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]
|
||||||
|
|
||||||
|
|
||||||
|
func _is_legal_ticket_transition(from: String, to: String) -> bool:
|
||||||
|
if from == to:
|
||||||
|
return true
|
||||||
|
var transitions := {
|
||||||
|
QUEUED: [PROPOSED, CANCELLED, EXPIRED],
|
||||||
|
PROPOSED: [QUEUED, ACCEPTED, CANCELLED, EXPIRED],
|
||||||
|
ACCEPTED: [QUEUED, ALLOCATING, CANCELLED, FAILED],
|
||||||
|
ALLOCATING: [PROCESS_READY, FAILED, CANCELLED],
|
||||||
|
PROCESS_READY: [ASSIGNMENT_READY, FAILED, CANCELLED],
|
||||||
|
ASSIGNMENT_READY: [ASSIGNED, FAILED, CANCELLED],
|
||||||
|
ASSIGNED: [CONNECTING, FAILED, CANCELLED],
|
||||||
|
CONNECTING: [LIVE, FAILED, EXPIRED],
|
||||||
|
LIVE: [RESULT_PENDING, FAILED],
|
||||||
|
RESULT_PENDING: [COMPLETED, FAILED],
|
||||||
|
}
|
||||||
|
return transitions.has(from) and to in transitions[from]
|
||||||
|
|
||||||
|
|
||||||
|
func _can_reach_ticket_state(from: String, to: String) -> bool:
|
||||||
|
if from == to:
|
||||||
|
return true
|
||||||
|
var pending: Array[String] = [from]
|
||||||
|
var visited := {}
|
||||||
|
visited[from] = true
|
||||||
|
while not pending.is_empty():
|
||||||
|
var current: String = pending.pop_front()
|
||||||
|
for candidate in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]:
|
||||||
|
if visited.has(candidate) or not _is_legal_ticket_transition(current, candidate):
|
||||||
|
continue
|
||||||
|
if candidate == to:
|
||||||
|
return true
|
||||||
|
visited[candidate] = true
|
||||||
|
pending.append(candidate)
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _is_legal_proposal_transition(from: String, to: String) -> bool:
|
||||||
|
if from.is_empty():
|
||||||
|
return to == "OPEN"
|
||||||
|
if from == to:
|
||||||
|
return true
|
||||||
|
return from == "OPEN" and to in ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
|
||||||
|
|
||||||
|
|
||||||
|
func _has_string(value: Dictionary, key: String) -> bool:
|
||||||
|
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func _valid_revision(value: Variant) -> bool:
|
||||||
|
if value is int:
|
||||||
|
return int(value) >= 0
|
||||||
|
if value is float:
|
||||||
|
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _valid_playlist(value: String) -> bool:
|
||||||
|
return value == "casual" or value == "ranked"
|
||||||
|
|
||||||
|
|
||||||
|
func _valid_epoch(value: Variant) -> bool:
|
||||||
|
if value is int:
|
||||||
|
return int(value) >= 0
|
||||||
|
if value is float:
|
||||||
|
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _valid_opaque_id(value: String) -> bool:
|
||||||
|
if value.length() < 16 or value.length() > 128:
|
||||||
|
return false
|
||||||
|
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
||||||
|
return resource_pattern.search(value) != null
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Plain data holder for one body's snapshot state (§2.4 of MULTIPLAYER_SPEC.md).
|
||||||
|
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
|
||||||
|
# node — NetCodec's pack/unpack must stay callable from pure-function tests
|
||||||
|
# with no live scene. Phase 2's snapshot writer fills one of these per body
|
||||||
|
# per tick from the real RigidBody3D state; Phase 2's interpolator does the
|
||||||
|
# reverse.
|
||||||
|
#
|
||||||
|
# avel_range must match what the sender quantised with (SHIP_AVEL_RANGE vs
|
||||||
|
# BALL_AVEL_RANGE in net_codec.gd) — it is not carried on the wire, because
|
||||||
|
# slot order already tells both peers which body is which (§1.3: "entities
|
||||||
|
# are addressed by integer slot, never by path").
|
||||||
|
|
||||||
|
var position := Vector3.ZERO
|
||||||
|
var rotation := Quaternion.IDENTITY
|
||||||
|
var linear_velocity := Vector3.ZERO
|
||||||
|
var angular_velocity := Vector3.ZERO
|
||||||
|
var frozen := false
|
||||||
|
var turbo := false
|
||||||
|
var thrust_z := 0.0 # -1..1; re-quantised to a 3-bit bin on the wire
|
||||||
|
var stalled := false
|
||||||
|
var avel_range := 4.0 # NetCodec.SHIP_AVEL_RANGE; set to BALL_AVEL_RANGE for the ball
|
||||||
|
|
||||||
|
# Self-referential preload, not get_script().new() — this file deliberately
|
||||||
|
# has no class_name (same cache-timing reason as test_case.gd and other
|
||||||
|
# path-`extends`d files in this project), and get_script().new() throws
|
||||||
|
# "Nonexistent function 'new' in base 'GDScript'" from within the script's
|
||||||
|
# own body in this Godot version.
|
||||||
|
const _NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# Same contract as ShipAction.copy() (see its own comment): a distinct
|
||||||
|
# instance with equal fields, for callers that hold onto a state past the
|
||||||
|
# tick/comparison it was returned in.
|
||||||
|
func copy() -> RefCounted:
|
||||||
|
var c := _NetBodyState.new()
|
||||||
|
c.position = position
|
||||||
|
c.rotation = rotation
|
||||||
|
c.linear_velocity = linear_velocity
|
||||||
|
c.angular_velocity = angular_velocity
|
||||||
|
c.frozen = frozen
|
||||||
|
c.turbo = turbo
|
||||||
|
c.thrust_z = thrust_z
|
||||||
|
c.stalled = stalled
|
||||||
|
c.avel_range = avel_range
|
||||||
|
return c
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bc1r0cqvtbqec
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
class_name NetCodec
|
||||||
|
|
||||||
|
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
|
||||||
|
# packets (§2 of MULTIPLAYER_SPEC.md). Pure functions only — no networking,
|
||||||
|
# no autoload state — so they're testable head-on by tests/test_runner.tscn
|
||||||
|
# without a live connection.
|
||||||
|
#
|
||||||
|
# Referenced from elsewhere via preload(), not the bare class_name, per the
|
||||||
|
# same global-script-class-cache caveat documented in tests/test_case.gd and
|
||||||
|
# sim_constants.gd.
|
||||||
|
|
||||||
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||||
|
const ShipAction = preload("res://scripts/ship_action.gd")
|
||||||
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||||
|
|
||||||
|
# --- Protocol ---
|
||||||
|
const PROTOCOL_VERSION := 1
|
||||||
|
const TICK_HZ: int = SimConstants.TICK_HZ
|
||||||
|
|
||||||
|
# --- Channels (logical intent; NetworkManager may need to offset these on
|
||||||
|
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
|
||||||
|
const CHANNEL_CONTROL := 0
|
||||||
|
const CHANNEL_INPUT := 1
|
||||||
|
const CHANNEL_SNAPSHOT := 2
|
||||||
|
|
||||||
|
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
|
||||||
|
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
|
||||||
|
|
||||||
|
# --- Input packet (§2.3) ---
|
||||||
|
const MAX_REDUNDANCY := 4
|
||||||
|
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
|
||||||
|
const INPUT_HEADER_SIZE := 12
|
||||||
|
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
|
||||||
|
const INPUT_FLAG_TURBO := 1 << 0
|
||||||
|
|
||||||
|
# --- Snapshot packet (§2.4) ---
|
||||||
|
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
|
||||||
|
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
|
||||||
|
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
|
||||||
|
const SNAPSHOT_BODY_HEADER_SIZE := 8
|
||||||
|
const SNAPSHOT_BODY_SIZE := 22
|
||||||
|
|
||||||
|
const BODY_FLAG_FROZEN := 1 << 0
|
||||||
|
const BODY_FLAG_TURBO := 1 << 1
|
||||||
|
const BODY_FLAG_THRUST_Z_SHIFT := 2
|
||||||
|
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
|
||||||
|
const BODY_FLAG_STALLED := 1 << 5
|
||||||
|
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
|
||||||
|
|
||||||
|
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
|
||||||
|
# restated prose; see MULTIPLAYER_SPEC.md for the ArenaBoundary/Ship/Ball
|
||||||
|
# constants these are sized against) ---
|
||||||
|
const POS_RANGE := 64.0 # metres, ±
|
||||||
|
const VEL_RANGE := 64.0 # m/s, ±
|
||||||
|
const QUAT_COMPONENT_RANGE := 1.0
|
||||||
|
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
|
||||||
|
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
|
||||||
|
|
||||||
|
const I16_MAX := 32767
|
||||||
|
const I8_MAX := 127
|
||||||
|
const THRUST_Z_BIN_MAX := 7 # 3 bits
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Quantisers — pure, reusable, independently testable.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
static func quantize_i16(value: float, range_max: float) -> int:
|
||||||
|
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
|
||||||
|
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
|
||||||
|
|
||||||
|
static func dequantize_i16(raw: int, range_max: float) -> float:
|
||||||
|
return (float(raw) / I16_MAX) * range_max
|
||||||
|
|
||||||
|
static func quantize_i8(value: float, range_max: float) -> int:
|
||||||
|
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
|
||||||
|
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
|
||||||
|
|
||||||
|
static func dequantize_i8(raw: int, range_max: float) -> float:
|
||||||
|
return (float(raw) / I8_MAX) * range_max
|
||||||
|
|
||||||
|
static func quantize_thrust_z_bin(thrust_z: float) -> int:
|
||||||
|
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
|
||||||
|
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
|
||||||
|
|
||||||
|
static func dequantize_thrust_z_bin(bin_value: int) -> float:
|
||||||
|
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
|
||||||
|
|
||||||
|
|
||||||
|
static func type_version_byte(type: PacketType) -> int:
|
||||||
|
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
|
||||||
|
|
||||||
|
static func packet_type_of(type_version: int) -> int:
|
||||||
|
return (type_version >> 4) & 0x0F
|
||||||
|
|
||||||
|
static func protocol_version_of(type_version: int) -> int:
|
||||||
|
return type_version & 0x0F
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Input packet — client -> server, channel 1 (§2.3)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
|
||||||
|
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
|
||||||
|
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
|
||||||
|
var buf := StreamPeerBuffer.new()
|
||||||
|
buf.put_u8(type_version_byte(PacketType.INPUT))
|
||||||
|
buf.put_u32(seq)
|
||||||
|
buf.put_u8(count)
|
||||||
|
buf.put_u32(ack_snapshot_tick)
|
||||||
|
buf.put_u16(client_send_ms & 0xFFFF)
|
||||||
|
for i in count:
|
||||||
|
var action: ShipAction = actions[i]
|
||||||
|
buf.put_8(quantize_i8(action.thrust.x, 1.0))
|
||||||
|
buf.put_8(quantize_i8(action.thrust.y, 1.0))
|
||||||
|
buf.put_8(quantize_i8(action.thrust.z, 1.0))
|
||||||
|
buf.put_8(quantize_i8(action.rotation.x, 1.0))
|
||||||
|
buf.put_8(quantize_i8(action.rotation.y, 1.0))
|
||||||
|
buf.put_8(quantize_i8(action.rotation.z, 1.0))
|
||||||
|
var flags := 0
|
||||||
|
if action.turbo:
|
||||||
|
flags |= INPUT_FLAG_TURBO
|
||||||
|
buf.put_u8(flags)
|
||||||
|
return buf.data_array
|
||||||
|
|
||||||
|
|
||||||
|
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
|
||||||
|
# client_send_ms, actions (Array[ShipAction], newest first).
|
||||||
|
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
|
||||||
|
var buf := StreamPeerBuffer.new()
|
||||||
|
buf.data_array = bytes
|
||||||
|
var type_version := buf.get_u8()
|
||||||
|
var seq := buf.get_u32()
|
||||||
|
var count := buf.get_u8()
|
||||||
|
var ack_snapshot_tick := buf.get_u32()
|
||||||
|
var client_send_ms := buf.get_u16()
|
||||||
|
var actions: Array[ShipAction] = []
|
||||||
|
for i in count:
|
||||||
|
var a := ShipAction.new()
|
||||||
|
a.thrust = Vector3(
|
||||||
|
dequantize_i8(buf.get_8(), 1.0),
|
||||||
|
dequantize_i8(buf.get_8(), 1.0),
|
||||||
|
dequantize_i8(buf.get_8(), 1.0)
|
||||||
|
)
|
||||||
|
a.rotation = Vector3(
|
||||||
|
dequantize_i8(buf.get_8(), 1.0),
|
||||||
|
dequantize_i8(buf.get_8(), 1.0),
|
||||||
|
dequantize_i8(buf.get_8(), 1.0)
|
||||||
|
)
|
||||||
|
var flags := buf.get_u8()
|
||||||
|
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
|
||||||
|
actions.append(a)
|
||||||
|
return {
|
||||||
|
"type_version": type_version,
|
||||||
|
"seq": seq,
|
||||||
|
"count": count,
|
||||||
|
"ack_snapshot_tick": ack_snapshot_tick,
|
||||||
|
"client_send_ms": client_send_ms,
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Snapshot packet — server -> client, channel 2 (§2.4)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Shared across every peer this tick — build once, reuse (§2.4's stated
|
||||||
|
# intent). Returns type_version + server_tick + match_state + reset_gen +
|
||||||
|
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
|
||||||
|
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
|
||||||
|
var buf := StreamPeerBuffer.new()
|
||||||
|
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
|
||||||
|
buf.put_u32(server_tick)
|
||||||
|
buf.put_u8(match_state & 0xFF)
|
||||||
|
buf.put_u8(reset_gen & 0xFF)
|
||||||
|
buf.put_u8(bodies.size())
|
||||||
|
for body in bodies:
|
||||||
|
var b: NetBodyState = body
|
||||||
|
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
|
||||||
|
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
|
||||||
|
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
|
||||||
|
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
|
||||||
|
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
|
||||||
|
var flags := 0
|
||||||
|
if b.frozen:
|
||||||
|
flags |= BODY_FLAG_FROZEN
|
||||||
|
if b.turbo:
|
||||||
|
flags |= BODY_FLAG_TURBO
|
||||||
|
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
|
||||||
|
if b.stalled:
|
||||||
|
flags |= BODY_FLAG_STALLED
|
||||||
|
if b.rotation.w < 0.0:
|
||||||
|
flags |= BODY_FLAG_QUAT_W_SIGN
|
||||||
|
buf.put_u8(flags)
|
||||||
|
return buf.data_array
|
||||||
|
|
||||||
|
|
||||||
|
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
|
||||||
|
var buf := StreamPeerBuffer.new()
|
||||||
|
buf.put_u32(last_input_seq)
|
||||||
|
buf.put_8(clampi(input_buffer_depth, -128, 127))
|
||||||
|
buf.put_u16(echo_client_send_ms & 0xFFFF)
|
||||||
|
return buf.data_array
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience: one full per-client packet = per-client header + shared body segment.
|
||||||
|
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
|
||||||
|
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
|
||||||
|
var out := PackedByteArray()
|
||||||
|
out.append_array(header)
|
||||||
|
out.append_array(body_segment)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
|
||||||
|
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
|
||||||
|
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
|
||||||
|
var buf := StreamPeerBuffer.new()
|
||||||
|
buf.data_array = bytes
|
||||||
|
var last_input_seq := buf.get_u32()
|
||||||
|
var input_buffer_depth := buf.get_8()
|
||||||
|
var echo_client_send_ms := buf.get_u16()
|
||||||
|
var type_version := buf.get_u8()
|
||||||
|
var server_tick := buf.get_u32()
|
||||||
|
var match_state := buf.get_u8()
|
||||||
|
var reset_gen := buf.get_u8()
|
||||||
|
var body_count := buf.get_u8()
|
||||||
|
var bodies: Array[NetBodyState] = []
|
||||||
|
for i in body_count:
|
||||||
|
var b := NetBodyState.new()
|
||||||
|
b.position = Vector3(
|
||||||
|
dequantize_i16(buf.get_16(), POS_RANGE),
|
||||||
|
dequantize_i16(buf.get_16(), POS_RANGE),
|
||||||
|
dequantize_i16(buf.get_16(), POS_RANGE)
|
||||||
|
)
|
||||||
|
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||||
|
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||||
|
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||||
|
b.linear_velocity = Vector3(
|
||||||
|
dequantize_i16(buf.get_16(), VEL_RANGE),
|
||||||
|
dequantize_i16(buf.get_16(), VEL_RANGE),
|
||||||
|
dequantize_i16(buf.get_16(), VEL_RANGE)
|
||||||
|
)
|
||||||
|
# avel_range is unknown to the codec at this point (it isn't on the
|
||||||
|
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
|
||||||
|
# the caller, which knows this slot's body kind, rescale if it's the
|
||||||
|
# ball's slot. Storing the raw i8 would avoid this, but every other
|
||||||
|
# field in this struct is already physical units; consistency wins.
|
||||||
|
b.angular_velocity = Vector3(
|
||||||
|
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
||||||
|
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
||||||
|
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
|
||||||
|
)
|
||||||
|
var flags := buf.get_u8()
|
||||||
|
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
|
||||||
|
b.turbo = (flags & BODY_FLAG_TURBO) != 0
|
||||||
|
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
|
||||||
|
b.thrust_z = dequantize_thrust_z_bin(bin_value)
|
||||||
|
b.stalled = (flags & BODY_FLAG_STALLED) != 0
|
||||||
|
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
|
||||||
|
var w := sqrt(maxf(w_sq, 0.0))
|
||||||
|
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
|
||||||
|
w = -w
|
||||||
|
b.rotation = Quaternion(qx, qy, qz, w)
|
||||||
|
bodies.append(b)
|
||||||
|
return {
|
||||||
|
"last_input_seq": last_input_seq,
|
||||||
|
"input_buffer_depth": input_buffer_depth,
|
||||||
|
"echo_client_send_ms": echo_client_send_ms,
|
||||||
|
"type_version": type_version,
|
||||||
|
"server_tick": server_tick,
|
||||||
|
"match_state": match_state,
|
||||||
|
"reset_gen": reset_gen,
|
||||||
|
"bodies": bodies,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
|
||||||
|
# assumption unpack_snapshot() decoded it with to the range it was actually
|
||||||
|
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
|
||||||
|
# immediately after unpack_snapshot(), using slot order to know which.
|
||||||
|
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
|
||||||
|
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
|
||||||
|
body.avel_range = actual_range
|
||||||
|
return
|
||||||
|
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
|
||||||
|
body.avel_range = actual_range
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bbb72h1ue0hdp
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
# Autoload: toggleable network debug overlay (F4 by default — see
|
||||||
|
# toggle_net_overlay in project.godot's [input]). Read-only against
|
||||||
|
# NetworkManager's clock state (task 1.8). Mirrors perf_overlay.gd's pattern
|
||||||
|
# — headless-guarded, hidden by default, no gameplay-state writes.
|
||||||
|
|
||||||
|
var _label: Label
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
set_process(false)
|
||||||
|
return
|
||||||
|
layer = 100
|
||||||
|
_label = Label.new()
|
||||||
|
_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
_label.add_theme_color_override("font_color", Color(0.5, 0.8, 1.0))
|
||||||
|
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
|
||||||
|
_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||||
|
_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||||
|
_label.position = Vector2(12, 90)
|
||||||
|
_label.visible = false
|
||||||
|
add_child(_label)
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("toggle_net_overlay") and _label:
|
||||||
|
_label.visible = not _label.visible
|
||||||
|
return
|
||||||
|
if not _label or not _label.visible or not (event is InputEventKey) or not event.pressed or event.echo:
|
||||||
|
return
|
||||||
|
var game := get_tree().get_first_node_in_group("game")
|
||||||
|
if game == null or not game.has_method("adjust_prediction_tuning"):
|
||||||
|
return
|
||||||
|
# Client-only live tuning: [/] threshold, -/= visual decay, ,/. visual
|
||||||
|
# offset, P present-time A/B. Deliberately no project input actions: these
|
||||||
|
# diagnostics never enter ShipAction or server/controller code.
|
||||||
|
match event.keycode:
|
||||||
|
KEY_BRACKETLEFT: game.adjust_prediction_tuning(-0.1)
|
||||||
|
KEY_BRACKETRIGHT: game.adjust_prediction_tuning(0.1)
|
||||||
|
KEY_MINUS: game.adjust_prediction_tuning(0.0, -0.01)
|
||||||
|
KEY_EQUAL: game.adjust_prediction_tuning(0.0, 0.01)
|
||||||
|
KEY_COMMA: game.adjust_prediction_tuning(0.0, 0.0, -0.05)
|
||||||
|
KEY_PERIOD: game.adjust_prediction_tuning(0.0, 0.0, 0.05)
|
||||||
|
KEY_P: game.adjust_prediction_tuning(0.0, 0.0, 0.0, true)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not _label or not _label.visible:
|
||||||
|
return
|
||||||
|
if NetworkManager.is_server:
|
||||||
|
_label.text = "NET: server, %d peer(s) out %s in %s" % [
|
||||||
|
MatchNet.roster.size(), _format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
|
||||||
|
]
|
||||||
|
elif NetworkManager.is_client:
|
||||||
|
if NetworkManager.rtt_ms < 0.0:
|
||||||
|
_label.text = "NET: client, connecting (no clock sample yet)"
|
||||||
|
else:
|
||||||
|
# task 3.7: RTT, jitter, loss, buffer depth, snapshot age,
|
||||||
|
# bandwidth all live here now. Prediction error is intentionally
|
||||||
|
# absent — there is no client-side prediction until Phase 4, so
|
||||||
|
# there is nothing honest to show for it yet. STALLED shows the
|
||||||
|
# server's own InputJitterBuffer.stalled bit for this client's
|
||||||
|
# slot, round-tripped through the wire.
|
||||||
|
var stats := {}
|
||||||
|
var game := get_tree().get_first_node_in_group("game")
|
||||||
|
if game and game.has_method("get_net_debug_stats"):
|
||||||
|
stats = game.get_net_debug_stats()
|
||||||
|
var stalled_suffix := " STALLED" if stats.get("server_stalled", false) else ""
|
||||||
|
var prediction: Dictionary = stats.get("prediction", {})
|
||||||
|
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s (target %s) lead %s loss %.1f%% snap age %.1fms%s\npred pos p50/p95/p99 %.3f / %.3f / %.3fm\npred rot p50/p95/p99 %.2f / %.2f / %.2fdeg snaps %.2f/min\nremote residual p99 %.3fm / %.2fdeg A/B present=%s\ntune [/] pos %.2f -/= decay ,/. offset %.2f P toggle\nout %s in %s" % [
|
||||||
|
NetworkManager.rtt_ms, NetworkManager.jitter_ms, NetworkManager.clock_offset_ms,
|
||||||
|
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_target_depth", "-")), str(stats.get("input_lead", "-")),
|
||||||
|
stats.get("snapshot_loss_pct", 0.0), stats.get("snapshot_age_ms", 0.0), stalled_suffix,
|
||||||
|
prediction.get("position_error_p50", 0.0), prediction.get("position_error_p95", 0.0), prediction.get("position_error_p99", 0.0),
|
||||||
|
prediction.get("rotation_error_p50", 0.0), prediction.get("rotation_error_p95", 0.0), prediction.get("rotation_error_p99", 0.0), prediction.get("hard_snap_rate_per_min", 0.0),
|
||||||
|
stats.get("remote_residual_position_p99", 0.0), stats.get("remote_residual_rotation_p99", 0.0), str(game.remote_visual_present_time_enabled if game else false),
|
||||||
|
prediction.get("position_threshold", 0.0), prediction.get("max_visual_offset", 0.0),
|
||||||
|
_format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
_label.text = "NET: offline"
|
||||||
|
|
||||||
|
|
||||||
|
func _format_kbps(bytes_per_sec: float) -> String:
|
||||||
|
return "%.2f KB/s" % (bytes_per_sec / 1000.0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cn2rdmwfo7phi
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
class_name NetInterpolator
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Buffers recent snapshot samples for ONE remote body and produces
|
||||||
|
# interpolated states at any requested (possibly fractional) server tick —
|
||||||
|
# used twice per body (MULTIPLAYER_SPEC.md §4.1/§4.6, "dual-time remote
|
||||||
|
# entities"): once at the present-time estimate for the collider, once
|
||||||
|
# further back at present-minus-INTERP_DELAY for $Visual.
|
||||||
|
#
|
||||||
|
# server_tick (Engine.get_physics_frames() at send time) maps to an
|
||||||
|
# estimated server wall-clock time via TICK_HZ without any extra
|
||||||
|
# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec()
|
||||||
|
# count from the same process-start epoch, and physics has been running at
|
||||||
|
# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ)
|
||||||
|
# is a valid estimate of "what Time.get_ticks_msec() read on the server when
|
||||||
|
# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms()
|
||||||
|
# reading into the same tick-space with to_tick(ms) before calling sample_at().
|
||||||
|
|
||||||
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||||
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||||
|
|
||||||
|
const MAX_SAMPLES := 16
|
||||||
|
# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one
|
||||||
|
# flying through a wall."
|
||||||
|
const MAX_EXTRAPOLATION_MS := 150.0
|
||||||
|
const TICK_MS := 1000.0 / SimConstants.TICK_HZ
|
||||||
|
|
||||||
|
var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first
|
||||||
|
var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset
|
||||||
|
|
||||||
|
|
||||||
|
static func to_tick(server_time_ms: float) -> float:
|
||||||
|
return server_time_ms / TICK_MS
|
||||||
|
|
||||||
|
|
||||||
|
# Returns true if this sample's reset_gen differs from the last one seen —
|
||||||
|
# the caller's cue to hard-snap instead of interpolating across a
|
||||||
|
# server-authoritative teleport (kickoff, goal reset) rather than sliding
|
||||||
|
# across the arena. Clears buffered history on a reset so a stale
|
||||||
|
# pre-reset sample can never bracket a post-reset one.
|
||||||
|
func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool:
|
||||||
|
# Never let a stale unreliable snapshot rewrite the epoch. The previous
|
||||||
|
# ordering cleared samples on its reset byte before checking tick order,
|
||||||
|
# so a delayed pre-reset packet could alternately flip generations and
|
||||||
|
# repeatedly cancel an active local ball handoff.
|
||||||
|
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
|
||||||
|
return false
|
||||||
|
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
|
||||||
|
if is_reset:
|
||||||
|
_samples.clear()
|
||||||
|
reset_gen = sample_reset_gen
|
||||||
|
_samples.append({"tick": server_tick, "state": state})
|
||||||
|
if _samples.size() > MAX_SAMPLES:
|
||||||
|
_samples.pop_front()
|
||||||
|
return is_reset
|
||||||
|
|
||||||
|
|
||||||
|
func has_samples() -> bool:
|
||||||
|
return not _samples.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func accepts_tick(server_tick: int) -> bool:
|
||||||
|
return _samples.is_empty() or server_tick > int(_samples.back()["tick"])
|
||||||
|
|
||||||
|
|
||||||
|
func latest() -> NetBodyState:
|
||||||
|
return _samples.back()["state"] if not _samples.is_empty() else null
|
||||||
|
|
||||||
|
|
||||||
|
# target_tick may be fractional (a point in time between two integer ticks).
|
||||||
|
func sample_at(target_tick: float) -> NetBodyState:
|
||||||
|
if _samples.is_empty():
|
||||||
|
return null
|
||||||
|
if _samples.size() == 1:
|
||||||
|
return _samples[0]["state"]
|
||||||
|
if target_tick <= _samples[0]["tick"]:
|
||||||
|
return _samples[0]["state"]
|
||||||
|
var newest: Dictionary = _samples.back()
|
||||||
|
if target_tick >= newest["tick"]:
|
||||||
|
return _extrapolate(newest, target_tick)
|
||||||
|
for i in range(_samples.size() - 1):
|
||||||
|
var a: Dictionary = _samples[i]
|
||||||
|
var b: Dictionary = _samples[i + 1]
|
||||||
|
if a["tick"] <= target_tick and target_tick <= b["tick"]:
|
||||||
|
var a_tick: float = a["tick"]
|
||||||
|
var b_tick: float = b["tick"]
|
||||||
|
var span := b_tick - a_tick
|
||||||
|
var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0
|
||||||
|
return _lerp_state(a["state"], b["state"], t)
|
||||||
|
return newest["state"]
|
||||||
|
|
||||||
|
|
||||||
|
func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState:
|
||||||
|
var out := NetBodyState.new()
|
||||||
|
out.position = a.position.lerp(b.position, t)
|
||||||
|
out.rotation = a.rotation.slerp(b.rotation, t)
|
||||||
|
out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t)
|
||||||
|
out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t)
|
||||||
|
out.frozen = b.frozen
|
||||||
|
out.turbo = b.turbo
|
||||||
|
out.thrust_z = b.thrust_z
|
||||||
|
out.stalled = b.stalled
|
||||||
|
out.avel_range = b.avel_range
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
|
||||||
|
var state: NetBodyState = newest["state"]
|
||||||
|
var ticks_ahead: float = target_tick - float(newest["tick"])
|
||||||
|
var ms_ahead := ticks_ahead * TICK_MS
|
||||||
|
var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS)
|
||||||
|
var out := NetBodyState.new()
|
||||||
|
out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0)
|
||||||
|
var angular_speed := state.angular_velocity.length()
|
||||||
|
if angular_speed > 0.00001:
|
||||||
|
out.rotation = (Quaternion(state.angular_velocity / angular_speed, angular_speed * (clamped_ms / 1000.0)) * state.rotation).normalized()
|
||||||
|
else:
|
||||||
|
out.rotation = state.rotation
|
||||||
|
out.linear_velocity = state.linear_velocity
|
||||||
|
out.angular_velocity = state.angular_velocity
|
||||||
|
out.frozen = state.frozen
|
||||||
|
out.turbo = state.turbo
|
||||||
|
out.thrust_z = state.thrust_z
|
||||||
|
out.stalled = state.stalled
|
||||||
|
out.avel_range = state.avel_range
|
||||||
|
return out
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cgb1vcapxami7
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user