Added building icons, ports, airports, shipyards and air/naval units
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M352 21v18h39v16h18V39h39V21h-96zm35 52-40 30h106l-40-30h-26zM69.48 120.5l-8.96 15.6 110.98 63.7 35.6 20.4 33.6-58.7-77 13.1-94.22-54.1zM329 121v30h30v-30h-30zm48 0v30h46v-30h-46zm64 0v30h30v-30h-30zm-103 48 34.5 46h55l34.5-46H338zm39 64v30h46v-30h-46zm0 48v206h46V281h-46zM68.52 329 34.3 375H359v-46H68.52zM25 393v94h30v-64h66v64h238v-94H25zm416 .5V487h46v-65.9l-46-27.6zM160 439h48v18h-48v-18zm96 0h48v18h-48v-18zm-183 2v46h30v-46H73z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 696 B |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M256 25.6 52 134.4h170v-56h68v55.094c2.53.258 5.04.567 7.54.906H460L256 25.6zm-16 70.8v57.38l-6.248 2.005c-14.24 4.573-26.103 11.94-36.07 22.322-17.686 18.425-26.735 43.597-26.735 77.368 0 33.38 8.89 58.507 26.282 77.144 10.118 10.787 22.11 18.3 36.477 22.83l6.293 1.984V420.4h32v-59.85l8.373-.587c9.6-.67 18.958-2.145 28.092-4.45 11.206-2.827 22.043-6.956 32.588-12.26V252.11h-61.035v19.974h30.955v49.576l-5.403 2.356c-4.373 1.906-9.156 3.264-14.322 4.152l-.08.014-.08.013c-5.003.768-10.33 1.133-15.988 1.133-21.365 0-39.558-7.483-51.834-22.228-11.08-13.308-17.89-27.01-17.89-51.625 0-24.844 7.066-38.687 18.532-51.912h.002c12.767-14.722 31.483-22.09 53.56-22.09 11.836 0 23.63 1.875 35.264 5.574 7.058 2.227 14.08 5.21 21.075 8.764v-32.35a138.957 138.957 0 0 0-26.992-9.155h-.006l-.008-.002c-8.43-1.94-17.185-3.16-26.287-3.65l-8.517-.458V96.4h-32zm-192 54v18h25v206H48v18h96v-18h-25v-206h25v-18H48zm320 0v18h25v206h-25v18h96v-18h-25v-206h25v-18h-96zm-327 258v30h181v-30H41zm249 0v30h181v-30H290zm-265 48v30h462v-30H25z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M247 32v23h-23v18h23v22h-39v18h39v22h-46.027l-2.54 10.154-18.408-9.205-8.05 16.102 21.988 10.994-10.578 42.312-25.592 13.96 23.691 110.564c-49.074 3.341-98.15 8.946-145.6 16.453l2.813 17.777a1639.075 1639.075 0 0 1 121.096-14.529l-.078.31c112.547 28.156 190.551 43.088 306.816-8.958l-7.355-16.43a485.085 485.085 0 0 1-26.844 11.092c-32.405-4.352-66.372-7.09-101.246-8.381l23.121-107.899-25.592-13.959-10.578-42.312 21.988-10.994-8.05-16.102-18.409 9.205L311.027 135H265v-22h39V95h-39V73h23V55h-23V32h-18zm-31.973 121h81.946l10.16 40.639L256 165.748l-51.133 27.89L215.027 153zM256 186.252v140.346c-5.41.103-10.833.238-16.262.402h-40.46l-21.071-98.316L256 186.252zM224 208a16 16 0 0 0-16 16 16 16 0 0 0 16 16 16 16 0 0 0 16-16 16 16 0 0 0-16-16zm68.17 0a16 16 0 0 1 16 16 16 16 0 0 1-16 16 16 16 0 0 1-16-16 16 16 0 0 1 16-16zm-50.92 137h82.404c22.502.709 44.618 2.01 66.149 3.96-58.924 14.561-109.381 9.793-169.532-3.194A1523.33 1523.33 0 0 1 241.25 345zm-132.865 29.363c-7.943-.023-15.667.234-23.084.842l1.469 17.941c54.39-4.455 133.014 12.49 189.199 17.202 55.64 4.665 109.966-1.684 168.654-13.512l-3.557-17.645c-57.8 11.65-110.279 17.692-163.591 13.221-47.153-3.954-113.49-17.885-169.09-18.049zm20.22 35.285c-12.198-.079-25.387.615-38.517 1.873-26.26 2.518-51.6 7.157-67.865 14.26l7.203 16.496c12.302-5.372 37.244-10.427 62.38-12.838 25.138-2.41 51.157-2.311 65.846.625 32.956 6.589 91.409 16.938 138.62 15.444l-.569-17.99c-44.053 1.394-102.073-8.619-134.523-15.106-9.17-1.833-20.376-2.684-32.575-2.764z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M220.3 22.738S90.967 121.512 87.466 156.875c-2.045 20.625 15.52 99.266 15.52 99.266s-17.602 39.467-33.96 79.432C59.515 358.81 51 434.418 51 434.418s75.3-8.543 98.44-18.092c39.8-16.428 79.11-34.11 79.11-34.11s78.57 18.053 99.235 15.966C363 394.622 461 264.424 461 264.424L220.3 22.738zm87.993 19.094-18.39 18.473 14.71 14.77 18.397-18.468-14.717-14.775zm114.023 3.71L348.762 119.4l14.71 14.774 73.56-73.856-14.716-14.777zm-66.203 7.38-36.775 36.935 14.71 14.772 36.784-36.933-14.72-14.775zm58.85 59.098-36.772 36.927 14.708 14.776 36.782-36.93-14.717-14.773zm11.043 48.01-18.387 18.468 14.706 14.773 18.395-18.467-14.714-14.774zM176.502 287.987c5.502.13 10.354 1.896 13.89 5.446 10.288 10.33 5.537 31.85-10.61 48.068-16.147 16.21-37.578 20.982-47.864 10.656-10.29-10.334-5.536-31.853 10.613-48.066 10.597-10.64 23.468-16.353 33.972-16.104zm57.076 104.024c-4.956-.228-11.433 5.645-15.42 14.416-4.557 10.023-4.135 20.03.94 22.357 5.073 2.324 12.88-3.912 17.437-13.935 4.556-10.022 4.136-20.032-.937-22.358a5.414 5.414 0 0 0-2.02-.48zm22.506 41.355c-4.955-.226-11.432 5.647-15.418 14.416-4.558 10.023-4.137 20.033.936 22.36 5.075 2.324 12.883-3.915 17.44-13.936 4.555-10.023 4.134-20.037-.94-22.36a5.366 5.366 0 0 0-2.018-.48zM295.027 452c-4.955-.227-11.43 5.646-15.42 14.416-4.556 10.022-4.135 20.033.94 22.36 5.073 2.324 12.882-3.916 17.437-13.938 4.557-10.022 4.136-20.032-.937-22.358a5.41 5.41 0 0 0-2.02-.48z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="m419.682 26.2-8.66 2.452L32.915 135.81 55.27 214.7l386.77-109.608L419.683 26.2zm-12.41 22.224 9.074 32.014-41.086-22.942 32.013-9.072zM350.77 64.438l56.8 31.714-37.084 10.51-56.8-31.715 37.084-10.51zm-61.577 17.45 56.803 31.716-37.084 10.51-56.8-31.718 37.08-10.51zm-61.574 17.45 56.802 31.715-37.084 10.51-56.803-31.715 37.084-10.51zm-61.577 17.45 56.803 31.716-37.084 10.51-56.8-31.717 37.08-10.51zm-61.574 17.45 56.8 31.715-37.083 10.51-56.802-31.715 37.084-10.51zm-45.86 26.227 41.085 22.94-32.01 9.072-9.074-32.012zM55 215v274h402V215H55zm18 18h33.273L73 266.273V233zm58.727 0h38.546l-46 46H85.727l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zM439 245.727V279h-33.273L439 245.727zM73 297h366v174H73V297zm248.635 46.57-192.44.703.067 18 192.44-.703-.067-18zM130.7 391.33l-.134 17.998 92.707.703.137-18-92.71-.7zm127.155.7-.2 18 63.913.702.2-17.998-63.913-.703z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M407 24.98v14.04h16V25zM88.99 25v14.03H105V25zM487 57H344.9v15.96H487zm-320 0H25v15.98h142zm256 33.93h-16.1v27.97l13.7.1h2.4zm-318 .1H88.97v28.07H105zm272 45.87-.1 32.1h-78.5l-16-32h-52.8l-16 32.1-78.6-.1v-32H55.03l-12.04 48L199 214.4V208c.1-31.4 25.7-56.9 57.1-57 31.3 0 57 25.6 56.9 57 0 4.1.1 6.4.1 6.4L469 185.1 457 137zM256.1 169c-21.6 0-39 17.4-39.1 39.1.1 21.5 17.5 38.9 39.1 38.9s39-17.4 38.9-39c.1-21.5-17.3-39-38.9-39zm-.2 14c13.8 0 25 11.3 25.1 25 0 13.7-11.3 25.1-25 25-13.7 0-25.1-11.3-25-24.9 0-13.8 11.3-25 24.9-25.1zm-54.7 40.5L215 279h82l13.9-55.4c-6.8 23.9-28.8 41.4-54.8 41.4-26.1 0-48.1-17.6-54.9-41.5zm-35.8 4.2L60.35 321.1l83.85 107.7c5-5 11.3-8.8 18.2-11.1L100.2 318l87.9-73-3.4-13.4zm181.3.2-19.4 3.8-3.3 13.4 87.8 73-62.2 99.6c7 2.3 13.2 6.2 18.3 11.3l83.8-107.8zM176 433.6c-15 0-26.9 11.9-26.9 26.7 0 14.9 11.9 26.7 27 26.8 14.9-.1 26.9-12 26.9-26.8-.1-14.8-11.9-26.7-27-26.7zm159.9 0c-14.9.1-27 11.9-26.9 26.8 0 14.7 11.9 26.6 27 26.6 15 0 27-11.8 26.9-26.7.1-14.8-11.9-26.7-27-26.7z" fill="#a5a5a5" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="m256 32.951 116.926 35.666 83.185 88.81 30.06 112.33-24.831 8.839-87.744-66.914-25.073 107.062-14.076 5.01H319.15L256 229.367l-63.15 94.387h-15.297l-14.076-5.01-25.073-107.062-87.744 66.914-24.832-8.838 30.06-112.33 83.186-88.81zm0 18.819-71.54 21.822 24.51 3.738.157-.246.139.291L256 84.502l46.734-7.127.14-.291.155.246 24.51-3.738zm108.672 34.367L313.6 93.926l51.023 80.113 18.404-68.305zm-217.344 0-18.355 19.597 18.404 68.305L198.4 93.926zm146.026 10.877L256 102.71l-37.354-5.697L256 175.217zm11.771 17.115-42.479 88.933 90.342-13.779zm-98.25 0-47.863 75.154 90.342 13.78zm190.629 7.06-15.992 59.352 51.133-21.836zm-283.008 0-35.14 37.516 51.132 21.838zm327.492 53.098-56.248 24.02 78.75 60.054zm-371.976 0L47.51 258.361l78.75-60.054zM356.203 207l-84.851 12.941 60.607 90.588zm-200.406 0 24.244 103.53 60.607-90.589zM487 288.264v32.14L334.447 375H177.553L25 320.404v-32.14l149.447 53.49h163.106zM328.193 393l13.43 94H170.377l13.428-94z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M461.5 31.85c-5 1.2-10.4 3.4-16.4 6.4-12 6-26.7 15.3-42.1 26.1-26.7 18.7-55.5 41.75-75 59.95l39.8 19.9 19.9 39.8c18.2-19.5 41.2-48.3 59.9-75 10.8-15.35 20.1-30.05 26.2-42.15 3-6 5.2-11.3 6.3-16.3 1.2-5 1.9-10.9-2.9-15.7-4.8-4.56-10-4.25-15.7-3zm-14.8 33.4c4.9 4.71 5.6 12.1 3.8 18.7-1.8 6.6-6.1 13.3-12.9 20.15l-42.2 42.2-29.6-29.7L408 74.45c6.8-6.8 13.5-11.06 20.1-12.9 6.1-1.71 14.3-.44 18.6 3.7zM183.2 109.5l-21.3 21.2 45.6 5v-26.2zm129.9 25.2-43.5 21.8-153 200.1 13.7 13.8 97.5-97.5 11.3 11.3c-32.5 32.5-65 65.1-97.5 97.6l13.8 13.6 200.1-153 21.8-43.5-21.4-42.8zm-256.59.4 7.4 22.2 120.99 83.5 64.5-84.3zm38.1 62.8-13.1 13.2 24.39 24.3 17.6-17.5zm260.89 64.7-84.3 64.5 83.5 121 22.2 7.3zm-190.9 4.8-110.99 9.1-22.6 22.6 82.39 35.4zm212.1 41.2 4.6 41.5 17.1-17.2v-24.3zm-132.1 38.8-67 51.2 35.3 82.4 22.6-22.6zm-138.7 21.2-13.09 13.1 37.49 37.4 13.1-13zm188.2 19.9-17.5 17.6 24.3 24.3 13.2-13.1z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M256.18 21c-23.242 0-46.577 3.01-63.186 8.54-8.304 2.763-14.868 6.196-18.808 9.558-3.94 3.36-5.167 5.956-5.186 8.96L168.943 57H41v14h430V57H342.967l.033-9.033c.01-3.002-1.17-5.55-5.057-8.895-3.887-3.344-10.407-6.773-18.677-9.535C302.724 24.014 279.42 21 256.18 21zM38.277 89l-10.443 94h80.9l20.243-86.36L122.81 183h81.07l17.114-86.68-3.096 86.68h75.75l-4.634-86.518L307.694 183h81.497l-6.167-86.36L403.266 183h80.9l-10.443-94H38.277zM25.834 201l-.51 4.588C39.822 226.445 52.968 235 64 235c11.32 0 24.852-8.89 39.8-30.96l.714-3.04h-78.68zm95.687 0-.32 4.498C135.753 226.495 148.935 235 160 235c11.293 0 24.788-8.85 39.693-30.803l.63-3.197h-78.802zm95.736 0-.156 4.352C231.69 226.455 244.908 235 256 235c11.08 0 24.28-8.525 38.85-29.576l-.237-4.424h-77.357zm94.324 0 .674 3.12c15 22.192 28.503 31.044 39.773 31.013 11.03-.03 24.212-8.62 38.772-29.637l-.32-4.496h-78.9zm95.906 0 .713 3.04C423.147 226.11 436.68 235 448 235c11.032 0 24.178-8.555 38.676-29.412l-.51-4.588h-78.68zM112 223.31C97.313 242.11 81.492 253 64 253c-13.972 0-26.884-6.906-39-19.264V487h318V279h114v208h30V233.736C474.884 246.094 461.972 253 448 253c-17.49 0-33.31-10.888-47.996-29.684-14.664 18.808-30.432 29.77-47.926 29.817-17.508.048-33.352-10.87-48.092-29.807C289.303 242.116 273.486 253 256 253c-17.492 0-33.313-10.89-48-29.69-14.687 18.8-30.508 29.69-48 29.69s-33.313-10.89-48-29.69zM55 279h258v178H55V279zm18 18v142h222V297H73zm288 0v71.064l78 .573V297h-78zM88 312h128c-108.235 8-116.31 24-128 113.11V312zm273 74.066v13.998l78 .573v-14.002l-78-.57zm0 32V487h78v-68.365l-78-.57z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M256 25c14.33 0 27.676 7.905 37.977 22.068C304.277 61.232 311 81.468 311 104c0 22.532-6.723 42.768-17.023 56.932C283.676 175.095 270.329 183 256 183c-14.33 0-27.676-7.905-37.977-22.068C207.723 146.768 201 126.532 201 104c0-22.532 6.722-42.768 17.023-56.932C228.324 32.905 241.671 25 256 25zm40 78h-80v18h31v23h18v-23h31zm4.777 77.732c22.269 3.505 48.815 9.312 84.93 17.334-18.385 31.94-30.507 71.784-36.947 105.024-30.784 3.249-71.261 9.48-92.76-11.819-23.106 21.245-68.115 17.842-92.838 11.424-6.459-33.161-18.556-72.814-36.869-104.629 36.115-8.022 62.661-13.829 84.93-17.334C223.35 193.18 238.89 201 256 201s32.65-7.82 44.777-20.268zM265 224h-18v48h18zm-8.992 91.117c25.254 11.781 65.5 11.202 89.556 7.113-1.059 7.611-1.768 14.623-2.12 20.77H168.556c-.358-6.232-1.08-13.351-2.164-21.084 29 2.217 65.796 6.81 89.615-6.799zM315 361v94h-18v-64h-82v64h-18v-94zm-36 48v46h-46v-46zm69.271 64 14 18H149.73l14-18z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M90.53 23c-18.345 0-36.688 7.002-50.686 21-27.996 27.996-27.994 73.38 0 101.375 21.776 21.776 54.08 26.603 80.53 14.5l53.69 53.688c-21.425 19.696-44 38.257-67.44 55.937l30.126 30.125c18.734-22.545 37.953-44.474 57.844-65.53l169.594 169.593c-51.845 40.444-120.866 53.838-192.813 42.562L173 424.906 72.47 404.47l95.405 88.405 1.97-26c86.593 36.97 177.603 34.61 241.343-11.75l63.062 21.313-21.47-63.594c44.61-63.62 46.408-153.412 9.908-238.875l26.03-1.97-88.406-95.375 20.438 100.53 21.344-1.624c11.278 71.983-2.168 141.017-42.656 192.876l-169.782-169.75c21.075-20.34 42.93-39.665 65.78-57.72l-30.123-30.124c-17.015 24.154-35.673 46.66-55.688 67.813l-53.97-53.97C167.834 98.183 163.032 65.814 141.22 44c-14-13.998-32.343-21-50.69-21zm0 27.03c11.434.002 22.872 4.34 31.595 13.064 17.447 17.447 17.446 45.742 0 63.187-17.446 17.447-45.71 17.447-63.156 0-17.447-17.444-17.448-45.74 0-63.186C67.69 54.37 79.097 50.03 90.53 50.03z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M367.717 25.318 325.94 63.475l49.953 38.296 26.002-50.247-34.178-26.205zm-62.71 41.608-38.562 49.242 70.936 55.66 38.604-49.12-70.978-55.782zm-50.495 64.28-58.035 73.85 118.628 88.885s3.513 2.384 5.48 5.904c2.02 3.612 1.81 6.935 1.81 6.935v128.544H297.14v-24.56H89.255v24.56H61.51v59.358h371.453V338.184a66.493 66.493 0 0 1-29.817 7.084c-36.706 0-66.463-29.758-66.462-66.465a66.462 66.462 0 0 1 33.26-57.48L254.51 131.206zM185 220.175l-68.21 87.103 112.83 1.668 27.495-34.984L185 220.175zm218.146 11.285a47.344 47.344 0 0 0-47.34 47.343 47.344 47.344 0 0 0 47.34 47.343 47.344 47.344 0 0 0 47.344-47.343 47.344 47.344 0 0 0-47.344-47.344zm0 17.67a29.674 29.674 0 0 1 29.676 29.673 29.674 29.674 0 0 1-29.676 29.674 29.674 29.674 0 0 1-29.673-29.674 29.674 29.674 0 0 1 29.673-29.674z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M25 24.99v30.02h56.69L111.3 137l-86.3-.1V183l122.2-.1C127.7 129 106.7 71.9 89.7 25zM25 201v247.8c.78-.5 1.52-1 2.32-1.5C40.85 439 56.99 430 72.92 430c15.98 0 27.58 10 37.68 18 10.1 8 18.5 14 26.4 14 7.9 0 16.4-6 26.4-14s21.6-17.9 37.5-17.9c16 0 27.6 9.9 37.7 17.8 10 8 18.5 14.1 26.4 14.1 1.1 0 2.2-.2 3.4-.4 8.8-7.1 19.7-17.6 31.1-30.4 19.9-22.3 42.2-51.5 63.1-82.2 20.7-30.8 40.1-63 54.1-91.2 11.3-22.7 18.7-43 21.2-56.8zm167 30h159.9l.1 18-159.9.1zm72.9 50v23h23l.1 18-23.1.1v58.3l.6-.3c4.5-2.8 9.4-6.9 14.1-11.4 4.5-4.6 8.6-9.6 11.4-14.1 2.7-4.5 3.9-8.5 3.9-9.5l18-.1c-.1 7.1-2.9 13.1-6.6 19.1-3.7 6-8.6 11.9-14 17.4-5.5 5.3-11.4 10.2-17.4 13.9-6 3.7-11.9 6.6-18.9 6.6-7.1 0-13-2.9-19-6.6-6.1-3.7-12-8.6-17.4-14-5.5-5.5-10.3-11.4-14.1-17.4-3.7-6-6.6-11.9-6.6-18.9l18-.1c0 1 1.2 5 3.9 9.5 2.8 4.6 6.9 9.5 11.5 14.2 4.6 4.5 9.6 8.6 14.2 11.4.1.1.3.2.4.3v-58.3h-23v-18h23v-23zm189.9 166.9c-7.1.2-14.7 5.6-24 13.3-10.1 8.2-21.6 18.8-37.8 18.7-16 0-27.6-9.9-37.7-17.8-10-8-18.5-14.1-26.3-14.1-8 0-16.4 6-26.4 14s-21.7 18-37.6 18c-15.9 0-27.6-10-37.7-17.9-10-8-18.5-14-26.3-14-8 0-16.4 6-26.4 14-10 7.9-21.6 17.9-37.5 17.9-16 0-27.7-10-37.71-18-10.06-8-18.53-14-26.46-14-7.9 0-23.71 7-36.19 14.6-9.17 5.7-15.32 10.2-18.74 12.8v18.7l475.9 1.4v-19.4c-2.1-2.6-6.4-7.2-12.8-13-8.7-7.8-20.1-14.7-24.9-15.1-.4 0-.9-.1-1.4-.1z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="m256 17.492 20.912 34.856L306 34.895 296.973 71h-81.946L206 34.896l29.088 17.452zm64.3 34.414C334.08 62.311 343 77 343 96v64.648c-10.638-7.27-22.444-13.917-34.984-18.63C311.239 132.817 313 122.663 313 112c0-8.018-1.005-15.744-1.489-19.372zm-128.6 0 8.789 40.722C200.005 96.256 199 103.982 199 112c0 10.663 1.76 20.817 4.984 30.018-12.54 4.713-24.346 11.36-34.984 18.63V96c0-19.001 8.92-33.689 22.7-44.094zM291.356 89c2.31 6.965 3.643 14.753 3.643 23 0 15.85-4.892 30.032-12.26 39.855C275.372 161.68 266.012 167 256 167s-19.372-5.32-26.74-15.145C221.892 142.032 217 127.85 217 112c0-8.247 1.334-16.035 3.643-23zm8.541 69.67c25.391 9.618 49.716 29.5 65.356 43.92-3.358 1.38-6.477 3.137-9.281 5.24-4.585 3.438-8.377 7.98-10.655 13.223l-23.134-5.783-4.368 17.46 6.9 1.725c2.53 16.035 3.235 31.57 3.249 43.781-5.706-6.514-12.54-12.787-21.897-14.996-3.203-.756-6.388-.989-9.529-.838-9.422.453-18.448 4.359-26.393 7.94A163.812 163.812 0 0 0 256 277.566a163.812 163.812 0 0 0-14.146-7.224c-10.593-4.775-23.11-10.126-35.922-7.102-9.358 2.21-16.19 8.482-21.897 14.996.014-12.212.72-27.746 3.248-43.78l6.9-1.726-4.367-17.46-23.134 5.783c-2.278-5.244-6.07-9.785-10.655-13.223-2.804-2.103-5.923-3.86-9.28-5.24 15.639-14.42 39.964-34.302 65.355-43.92a71.055 71.055 0 0 0 2.757 3.984c6.988 9.318 16.241 16.507 26.805 20.004L256 240l14.336-57.342c10.564-3.497 19.817-10.686 26.805-20.004a71.06 71.06 0 0 0 2.757-3.984zM384 217c7.013 0 13.194 2.204 17.227 5.229 3.974 2.98 5.704 6.29 5.753 9.625-.292.051-.395.103-.884.132-3.949.234-12.367-.986-22.096-.986s-18.147 1.22-22.096.986c-.49-.029-.592-.081-.884-.132.05-3.336 1.779-6.645 5.753-9.625C370.806 219.204 376.987 217 384 217zm-256 0c7.013 0 13.194 2.204 17.227 5.229 3.974 2.98 5.704 6.29 5.753 9.625-.292.051-.395.103-.884.132-3.949.234-12.367-.986-22.096-.986s-18.147 1.22-22.096.986c-.49-.029-.592-.081-.884-.132.05-3.336 1.779-6.645 5.753-9.625C114.806 219.204 120.987 217 128 217zm279 32v174h-46V249zm-256 0v174h-46V249zm149.23 31.6c.677 0 1.251.054 1.702.16 2.735.646 10.877 7.205 16.595 14.224 4.698 5.766 7.671 10.392 8.8 12.182l11.118 88.953c-3.57-4.158-7.477-8.167-11.738-11.615C320.277 379.3 312.839 375 304 375c-20.5 0-34.72 14.856-43.488 28.008a111.855 111.855 0 0 0-4.512 7.39 111.855 111.855 0 0 0-4.512-7.39C242.72 389.856 228.5 375 208 375c-8.839 0-16.276 4.3-22.707 9.504-4.26 3.448-8.169 7.457-11.738 11.615l11.119-88.953c1.128-1.79 4.101-6.416 8.799-12.182 5.718-7.019 13.86-13.578 16.595-14.224 3.603-.85 15.085 1.798 24.389 5.992a144.83 144.83 0 0 1 5.945 2.857L256 352l15.598-62.39c1.85-.939 3.84-1.909 5.945-2.858 6.978-3.146 15.182-5.423 20.367-6.006a20.831 20.831 0 0 1 2.32-.146zM304 393c2.475 0 6.694 1.7 11.385 5.496 4.69 3.796 9.634 9.323 13.949 14.969 2.518 3.294 4.72 6.502 6.717 9.535h-66.31c1.592-3.153 3.419-6.515 5.747-10.008C282.72 402.144 292.5 393 304 393zm-96 0c11.5 0 21.28 9.144 28.512 19.992 2.328 3.493 4.155 6.855 5.748 10.008h-66.31c1.995-3.033 4.198-6.24 6.716-9.535 4.315-5.646 9.259-11.173 13.95-14.969C201.305 394.7 205.525 393 208 393zm231 48v14H73v-14zm48 32v14H25v-14z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M256 89.61 22.486 177.18 256 293.937l111.22-55.61-104.337-31.9A16 16 0 0 1 256 208a16 16 0 0 1-16-16 16 16 0 0 1 16-16l-2.646 8.602 18.537 5.703a16 16 0 0 1 .008.056l27.354 8.365L455 246.645v12.146a16 16 0 0 0-7 13.21 16 16 0 0 0 7.293 13.406C448.01 312.932 448 375.383 448 400c16 10.395 16 10.775 32 0 0-24.614-.008-87.053-7.29-114.584A16 16 0 0 0 480 272a16 16 0 0 0-7-13.227v-25.42L413.676 215.1l75.838-37.92L256 89.61zM119.623 249 106.5 327.74c26.175 3.423 57.486 18.637 86.27 36.627 16.37 10.232 31.703 21.463 44.156 32.36 7.612 6.66 13.977 13.05 19.074 19.337 5.097-6.288 11.462-12.677 19.074-19.337 12.453-10.897 27.785-22.128 44.156-32.36 28.784-17.99 60.095-33.204 86.27-36.627L392.375 249h-6.25L256 314.063 125.873 249h-6.25z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -632,6 +632,13 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.row-card .info { flex: 1; }
|
||||
.row-card .building-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.row-card .name { font-weight: 600; }
|
||||
.row-card .desc { color: var(--muted); font-size: 12px; margin: 3px 0; }
|
||||
.row-card .effects { font-size: 12px; color: #bcd4ff; }
|
||||
|
||||
@@ -204,6 +204,24 @@ export class GameScreen {
|
||||
// foreign territory at peace. `type` is "move", "attack" or "schedule".
|
||||
async _orderMove(units, coords, type = "move") {
|
||||
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
|
||||
// Aircraft are refused an order outside their mission radius before it is
|
||||
// ever sent, so the browser and the server never disagree on a route.
|
||||
const outOfRange = units.some((unit) => {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
if (!proto || !proto.air) return false;
|
||||
const route = routes.get(unit.id);
|
||||
if (!route) return false;
|
||||
// A bombing run is limited by the strike range; a ferry flight by the
|
||||
// aircraft's mission radius.
|
||||
const limit = type === "attack" && proto.strike
|
||||
? (proto.range || 1)
|
||||
: (proto.missionRange || Infinity);
|
||||
return route.length - 1 > limit;
|
||||
});
|
||||
if (outOfRange) {
|
||||
this.confirmModal.show("Out of range", "That target lies beyond this aircraft's reach.", null);
|
||||
return;
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
const names = targets.map((index) => this._civName(index)).join(", ");
|
||||
const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
|
||||
@@ -227,6 +245,13 @@ export class GameScreen {
|
||||
order.append = true;
|
||||
}
|
||||
this.sendOrder(order);
|
||||
// A bombing run is resolved instantly on the server without moving, so
|
||||
// predicting a march there would only make the icon drift.
|
||||
const strike = type === "attack" && units.every((unit) => {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return proto && proto.strike;
|
||||
});
|
||||
if (strike) return Promise.resolve();
|
||||
const pending = [];
|
||||
for (const unit of units) {
|
||||
// An append joins the queue behind the leg already under way, so only an
|
||||
|
||||
@@ -526,16 +526,24 @@ export const panelMethods = {
|
||||
|
||||
_cityView(city) {
|
||||
const stats = this.snapshot.viewerStats || {};
|
||||
const levels = this._cityBuildings.get(city.id) || {};
|
||||
const hasBuilding = (id) => {
|
||||
const index = BUILDINGS.findIndex((b) => b.id === id);
|
||||
return index >= 0 && (levels[index] || 0) > 0;
|
||||
};
|
||||
// Aircraft and warships only appear once the city has the installation
|
||||
// that trains them, so the order can never be sent and rejected.
|
||||
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
|
||||
const index = this.protoUnits.findIndex((p) => p.id === id);
|
||||
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
|
||||
}).filter(Boolean);
|
||||
}).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
|
||||
return {
|
||||
budget: stats.budget || 0,
|
||||
gdp: stats.gdp || 0,
|
||||
coastal: !!city.coastal,
|
||||
trainable,
|
||||
buildings: BUILDINGS,
|
||||
levels: this._cityBuildings.get(city.id) || {},
|
||||
levels,
|
||||
queue: this._trainingQueue(city.id),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -386,12 +386,13 @@ export const motionMethods = {
|
||||
// route never cuts through a third party and provokes a war.
|
||||
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1) {
|
||||
const k = key(coords.x, coords.y);
|
||||
if (!this.explored.has(k)) return false;
|
||||
const air = !!(proto && proto.air);
|
||||
const tile = this.tiles[k];
|
||||
if (!tile) return false;
|
||||
if (!air && !this.explored.has(k)) return false;
|
||||
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
|
||||
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
|
||||
if (!isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
|
||||
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
|
||||
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
|
||||
const hostile = occupants.filter((data) => data.civ !== civ);
|
||||
if (hostile.length > 0) {
|
||||
@@ -402,6 +403,8 @@ export const motionMethods = {
|
||||
}
|
||||
const cityCiv = this._cityCiv.get(k);
|
||||
if (cityCiv !== undefined && cityCiv !== civ) {
|
||||
// Mirrors GameState._canUnitEnter: aircraft never occupy an enemy city.
|
||||
if (air) return false;
|
||||
if (!isGoal) return false;
|
||||
if (proto.military !== true) return false;
|
||||
}
|
||||
@@ -409,13 +412,16 @@ export const motionMethods = {
|
||||
},
|
||||
|
||||
_stepHours(proto, civ, coords, speed = null) {
|
||||
const effective = Math.max(speed || proto.speed, 0.0001);
|
||||
// Mirrors GameState._tileTravelHours: aircraft cross every tile at a flat
|
||||
// rate, while ground units are shaped by roads and terrain.
|
||||
if (proto.air) return 1 / effective;
|
||||
const k = key(coords.x, coords.y);
|
||||
let effective = Math.max(speed || proto.speed, 0.0001);
|
||||
if (this.territory.get(k) === civ) effective *= CONTROLLED_SPEED_MULTIPLIER;
|
||||
// Mirrors GameState._tileTravelHours: a road levels the terrain.
|
||||
let pace = effective;
|
||||
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
|
||||
const multiplier = this.roads.has(k)
|
||||
? ROADS.movementCostMultiplier
|
||||
: this.terrainStats.movementCostMultiplier(coords);
|
||||
return multiplier / effective;
|
||||
return multiplier / pace;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,6 +31,8 @@ export class CityModal {
|
||||
|
||||
this.cityId = 0;
|
||||
this.gdp = 0;
|
||||
this.coastal = false;
|
||||
this._levels = {};
|
||||
this.protoNames = new Map();
|
||||
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
|
||||
this.buildingRows = [];
|
||||
@@ -62,6 +64,7 @@ export class CityModal {
|
||||
show(city, view = {}) {
|
||||
this.cityId = city.id;
|
||||
this.gdp = view.gdp || 0;
|
||||
this.coastal = !!view.coastal;
|
||||
this.$title.text(`${city.name} — City`);
|
||||
this._renderTrainList(this.gdp, view.trainable || []);
|
||||
this.renderQueue(view.queue || []);
|
||||
@@ -160,6 +163,9 @@ export class CityModal {
|
||||
|
||||
_makeBuildingRow(proto, index) {
|
||||
const $card = $("<div class='row-card'></div>");
|
||||
const $icon = proto.icon
|
||||
? $("<img class='building-icon' />").attr({ src: `assets/${proto.icon}`, alt: proto.name })
|
||||
: null;
|
||||
const $info = $("<div class='info'></div>");
|
||||
const $name = $("<div class='name'></div>");
|
||||
const $desc = $("<div class='desc'></div>").text(proto.description);
|
||||
@@ -172,12 +178,28 @@ export class CityModal {
|
||||
$upgrade.on("click", () => this.onBuild(this.cityId, index));
|
||||
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
|
||||
$actions.append($upgrade, $downgrade);
|
||||
if ($icon) $card.append($icon);
|
||||
$card.append($info, $actions);
|
||||
this.$buildingsList.append($card);
|
||||
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
|
||||
}
|
||||
|
||||
// Why a building cannot be raised here, or null when it can. Ports need the
|
||||
// sea and shipyards need a port, mirrored from GameState.requestBuild.
|
||||
_buildRequirement(proto) {
|
||||
if (proto.coastal && !this.coastal) return "Needs a coastal city";
|
||||
if (proto.requiresBuilding) {
|
||||
const index = BUILDINGS.findIndex((b) => b.id === proto.requiresBuilding);
|
||||
if (index < 0 || (this._levels[index] || 0) <= 0) {
|
||||
const name = index >= 0 ? BUILDINGS[index].name : proto.requiresBuilding;
|
||||
return `Needs a ${name}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
|
||||
this._levels = levels || {};
|
||||
this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`);
|
||||
for (const row of this.buildingRows) {
|
||||
const level = levels[row.index] || 0;
|
||||
@@ -193,8 +215,12 @@ export class CityModal {
|
||||
const cost = buildingBuildCost(row.proto, nextLevel);
|
||||
const duration = ECONOMY.trainingHours(gdp, cost);
|
||||
parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`);
|
||||
const requirement = this._buildRequirement(row.proto);
|
||||
if (requirement) parts.push(requirement);
|
||||
row.$cost.text(parts.join(" · "));
|
||||
row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
|
||||
row.$upgrade
|
||||
.text(`Upgrade €${groupDigits(Math.round(cost))}`)
|
||||
.prop("disabled", !!requirement || budget < cost);
|
||||
if (level > 0) {
|
||||
row.$downgrade
|
||||
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
|
||||
|
||||
@@ -128,7 +128,11 @@ export class GameServer extends EventEmitter {
|
||||
_handleAttack(civ, order) {
|
||||
const units = this._ownUnitIds(civ, order);
|
||||
if (units.length === 0) return false;
|
||||
return this.state.requestAttack(units, coordsOf(order.coords));
|
||||
const coords = coordsOf(order.coords);
|
||||
// A group of bombers bombs from afar; anything else charges in.
|
||||
const allStrike = units.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
|
||||
if (allStrike) return this.state.requestStrike(units, coords);
|
||||
return this.state.requestAttack(units, coords);
|
||||
}
|
||||
|
||||
_handleSchedule(civ, order) {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const BUILDINGS = [
|
||||
costAlpha: 1.6,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
|
||||
icon: "icon_market.svg",
|
||||
},
|
||||
{
|
||||
id: "bank",
|
||||
@@ -22,6 +23,7 @@ export const BUILDINGS = [
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
|
||||
icon: "icon_bank.svg",
|
||||
},
|
||||
{
|
||||
id: "research_lab",
|
||||
@@ -31,6 +33,7 @@ export const BUILDINGS = [
|
||||
costAlpha: 1.65,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)],
|
||||
icon: "icon_research_lab.svg",
|
||||
},
|
||||
{
|
||||
id: "university",
|
||||
@@ -40,6 +43,7 @@ export const BUILDINGS = [
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)],
|
||||
icon: "icon_university.svg",
|
||||
},
|
||||
{
|
||||
id: "museum",
|
||||
@@ -49,6 +53,7 @@ export const BUILDINGS = [
|
||||
costAlpha: 1.6,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_CULTURE, 1.5, 0.85)],
|
||||
icon: "icon_museum.svg",
|
||||
},
|
||||
{
|
||||
id: "cinema_studio",
|
||||
@@ -58,6 +63,40 @@ export const BUILDINGS = [
|
||||
costAlpha: 1.75,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_CULTURE, 3.0, 0.7)],
|
||||
icon: "icon_cinema_studio.svg",
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
name: "Port",
|
||||
description: "A deep-water harbour. Only a coastal city can raise one. Raises the tax collected from the city's region and is required before a shipyard.",
|
||||
baseCost: 20_000_000,
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_BUDGET_INCOME, 0.08, 0.7)],
|
||||
icon: "icon_port.svg",
|
||||
coastal: true,
|
||||
},
|
||||
{
|
||||
id: "airport",
|
||||
name: "Airport",
|
||||
description: "An airfield with a control tower. Lets the city train aircraft and serves as a home airbase for them to land and refuel.",
|
||||
baseCost: 250_000_000,
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_GDP, 0.05, 0.8)],
|
||||
icon: "icon_airport.svg",
|
||||
},
|
||||
{
|
||||
id: "shipyard",
|
||||
name: "Shipyard",
|
||||
description: "Naval yards that build warships. Only a coastal city can raise one, and it must already have a port.",
|
||||
baseCost: 150_000_000,
|
||||
costAlpha: 1.65,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_BUDGET_INCOME, 0.06, 0.7)],
|
||||
icon: "icon_shipyard.svg",
|
||||
coastal: true,
|
||||
requiresBuilding: "port",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -52,10 +52,76 @@ export const PROTO_UNITS = [
|
||||
traversableTerrains: ["Land"],
|
||||
icon: "icon_artillery.svg",
|
||||
},
|
||||
{
|
||||
id: "jet_fighter",
|
||||
name: "Jet fighter",
|
||||
maxHp: 300,
|
||||
cost: 400_000_000,
|
||||
upkeep: 20_000,
|
||||
moveable: true,
|
||||
speed: 5,
|
||||
military: true,
|
||||
attack: 300,
|
||||
defense: 20,
|
||||
range: 1,
|
||||
traversableTerrains: ["Land", "Sea", "Ice"],
|
||||
// Aircraft only operate near a friendly airport: `enduranceHours` is how
|
||||
// long they may stay aloft and `missionRange` the furthest they may be
|
||||
// ordered from home. Both are per-type so a future plane can differ.
|
||||
air: true,
|
||||
enduranceHours: 2,
|
||||
missionRange: 5,
|
||||
requiresBuilding: "airport",
|
||||
icon: "icon_jet_fighter.svg",
|
||||
},
|
||||
{
|
||||
id: "bomber",
|
||||
name: "Bomber",
|
||||
maxHp: 350,
|
||||
cost: 500_000_000,
|
||||
upkeep: 25_000,
|
||||
moveable: true,
|
||||
speed: 5,
|
||||
military: true,
|
||||
attack: 450,
|
||||
defense: 10,
|
||||
// A bomber strikes from `range` tiles away and never joins the melee.
|
||||
range: 2,
|
||||
traversableTerrains: ["Land", "Sea", "Ice"],
|
||||
air: true,
|
||||
enduranceHours: 2,
|
||||
missionRange: 5,
|
||||
strike: true,
|
||||
strikeHours: 1,
|
||||
requiresBuilding: "airport",
|
||||
icon: "icon_bomber.svg",
|
||||
},
|
||||
{
|
||||
id: "battleship",
|
||||
name: "Battleship",
|
||||
maxHp: 800,
|
||||
cost: 600_000_000,
|
||||
upkeep: 30_000,
|
||||
moveable: true,
|
||||
speed: 0.5,
|
||||
military: true,
|
||||
attack: 400,
|
||||
defense: 120,
|
||||
range: 2,
|
||||
traversableTerrains: ["Sea"],
|
||||
requiresBuilding: "shipyard",
|
||||
icon: "icon_battleship.svg",
|
||||
},
|
||||
];
|
||||
|
||||
// Only units in this list can be trained from the city screen.
|
||||
export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
|
||||
export const TRAINABLE_UNIT_IDS = [
|
||||
"modern_infantry",
|
||||
"artillery",
|
||||
"jet_fighter",
|
||||
"bomber",
|
||||
"battleship",
|
||||
];
|
||||
|
||||
export function protoUnitById(id) {
|
||||
return PROTO_UNITS.find((u) => u.id === id) || null;
|
||||
|
||||
@@ -39,6 +39,7 @@ import { territoryMethods } from "./game_state/territory.js";
|
||||
import { economyMethods } from "./game_state/economy.js";
|
||||
import { movementMethods } from "./game_state/movement.js";
|
||||
import { combatMethods } from "./game_state/combat.js";
|
||||
import { airMethods } from "./game_state/air.js";
|
||||
import { statusMethods } from "./game_state/statuses.js";
|
||||
import { diplomacyMethods } from "./game_state/diplomacy.js";
|
||||
import { orderMethods } from "./game_state/orders.js";
|
||||
@@ -234,6 +235,7 @@ export class GameState {
|
||||
tickHour() {
|
||||
this.totalHours += 1;
|
||||
this._tickTraining();
|
||||
this._tickAirUnits();
|
||||
this._tickGarrisonHealing();
|
||||
if (this._visibilityDirty) {
|
||||
this._refreshVisibility();
|
||||
@@ -266,6 +268,7 @@ Object.assign(
|
||||
economyMethods,
|
||||
movementMethods,
|
||||
combatMethods,
|
||||
airMethods,
|
||||
statusMethods,
|
||||
diplomacyMethods,
|
||||
orderMethods,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Air power and the placement rules shared by coastal and gated buildings:
|
||||
// where a port may be raised, which cities may train aircraft or warships, and
|
||||
// the endurance model that keeps aircraft tied to a friendly airport.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
|
||||
export const airMethods = {
|
||||
// ----------------------------------------------------- building rules --
|
||||
|
||||
buildingIndexById(id) {
|
||||
return this.protoBuildings.findIndex((building) => building.id === id);
|
||||
},
|
||||
|
||||
// Whether the city has raised at least one level of the building with `id`.
|
||||
hasCityBuilding(city, id) {
|
||||
const index = this.buildingIndexById(id);
|
||||
return index >= 0 && this.getCityBuildingLevel(city, index) > 0;
|
||||
},
|
||||
|
||||
// A tile is coastal when at least one of its neighbours is sea.
|
||||
isCoastalCoords(coords) {
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (tile && tile.terrainClass === "Sea") return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
isCoastalCity(city) {
|
||||
return this.isCoastalCoords(city.coords);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- air --
|
||||
|
||||
_isAirProto(proto) {
|
||||
return !!(proto && proto.air);
|
||||
},
|
||||
|
||||
_isAirUnit(unit) {
|
||||
return this._isAirProto(this.unitProto(unit));
|
||||
},
|
||||
|
||||
_isStrikeUnit(unit) {
|
||||
const proto = this.unitProto(unit);
|
||||
return !!(proto && proto.strike);
|
||||
},
|
||||
|
||||
// The friendly airport city on `coords`, or null. An aircraft parked here
|
||||
// refuels and stops burning endurance.
|
||||
_friendlyAirportAt(coords, civ) {
|
||||
const city = this.cityAt(coords);
|
||||
if (!city || city.civ !== civ) return null;
|
||||
return this.hasCityBuilding(city, "airport") ? city : null;
|
||||
},
|
||||
|
||||
// The airport an aircraft flies home to: its own base when still friendly,
|
||||
// otherwise the nearest friendly airport anywhere.
|
||||
_nearestFriendlyAirport(unit) {
|
||||
const home = this.findCity(unit.homeCityId);
|
||||
if (home && home.civ === unit.civ && this.hasCityBuilding(home, "airport")) return home;
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== unit.civ || !this.hasCityBuilding(city, "airport")) continue;
|
||||
const distance = this.topology.tileDistance(unit.coords, city.coords);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = city;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
},
|
||||
|
||||
_pathHours(unit, path) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]);
|
||||
return total;
|
||||
},
|
||||
|
||||
// Routes an aircraft back to a friendly airport, unless the trip would run
|
||||
// past its remaining endurance, in which case it stays put and will crash.
|
||||
_orderAirReturn(unit) {
|
||||
const airport = this._nearestFriendlyAirport(unit);
|
||||
if (!airport) return false;
|
||||
const path = this.findPath(unit, unit.coords, airport.coords);
|
||||
if (path.length < 2) return false;
|
||||
const proto = this.unitProto(unit);
|
||||
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
|
||||
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
|
||||
unit.path = path;
|
||||
unit.pathIndex = 0;
|
||||
unit.progressHours = 0;
|
||||
unit.moveSpeed = null;
|
||||
unit.waypoints = [];
|
||||
unit.autoReturn = true;
|
||||
return true;
|
||||
},
|
||||
|
||||
// Runs once per in-game hour: burns one hour of endurance for every airborne
|
||||
// aircraft, lands and refuels the ones sitting on a friendly airport, crashes
|
||||
// the ones that ran dry, and sends the idle ones home. Aircraft whose fuel is
|
||||
// about to run out turn for home even mid-route, so only losing every airport
|
||||
// can strand a plane.
|
||||
_tickAirUnits() {
|
||||
const crashed = [];
|
||||
for (const unit of this.units) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!this._isAirProto(proto)) continue;
|
||||
if (this._friendlyAirportAt(unit.coords, unit.civ)) {
|
||||
unit.airHours = proto.enduranceHours;
|
||||
unit.airborne = false;
|
||||
unit.autoReturn = false;
|
||||
continue;
|
||||
}
|
||||
if (unit.airHours === undefined || unit.airHours === null) {
|
||||
unit.airHours = proto.enduranceHours;
|
||||
}
|
||||
unit.airborne = true;
|
||||
unit.airHours -= 1;
|
||||
if (unit.airHours <= 0) crashed.push(unit);
|
||||
}
|
||||
for (const unit of crashed) {
|
||||
this._recordMilitaryCasualties([unit]);
|
||||
this._destroyUnit(unit);
|
||||
}
|
||||
if (crashed.length > 0) this._visibilityDirty = true;
|
||||
|
||||
for (const unit of this.units) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!this._isAirProto(proto) || !unit.airborne || unit.autoReturn) continue;
|
||||
if (this._hasRoute(unit)) {
|
||||
// Turn for home before the tanks run dry.
|
||||
const airport = this._nearestFriendlyAirport(unit);
|
||||
if (!airport) continue;
|
||||
const home = this.findPath(unit, unit.coords, airport.coords);
|
||||
if (home.length < 2) continue;
|
||||
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
|
||||
if (this._pathHours(unit, home) >= fuel - 1e-9) this._orderAirReturn(unit);
|
||||
continue;
|
||||
}
|
||||
this._orderAirReturn(unit);
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------- strike --
|
||||
|
||||
_strikeTargetAt(coords, civ) {
|
||||
const enemies = this.unitsAt(coords).filter(
|
||||
(unit) => unit.civ !== civ && this._isMilitary(unit)
|
||||
);
|
||||
if (enemies.length === 0) return null;
|
||||
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
|
||||
return enemies[0];
|
||||
},
|
||||
|
||||
// Whether every aircraft in the group may bomb `goal`: all strike-capable,
|
||||
// within strike range, inside their mission radius and still carrying fuel.
|
||||
canStrike(unitIds, goal) {
|
||||
const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
|
||||
if (units.length === 0) return false;
|
||||
const civ = units[0].civ;
|
||||
if (!this._strikeTargetAt(goal, civ)) return false;
|
||||
return units.every((unit) => this._strikeReady(unit, goal));
|
||||
},
|
||||
|
||||
_strikeReady(unit, goal) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto) return false;
|
||||
const distance = this.topology.tileDistance(unit.coords, goal);
|
||||
if (distance > (proto.range || 1)) return false;
|
||||
if (distance > (proto.missionRange || distance)) return false;
|
||||
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
|
||||
return fuel > 0;
|
||||
},
|
||||
|
||||
// A bombing run: every strike aircraft damages the weakest hostile military
|
||||
// unit on `goal` without moving and without joining the melee. The sortie
|
||||
// burns `strikeHours` of endurance, so bombers must still return to an
|
||||
// airport. Declares war on the target if the two were at peace.
|
||||
requestStrike(unitIds, goal) {
|
||||
const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
|
||||
if (units.length === 0) return false;
|
||||
const civ = units[0].civ;
|
||||
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
|
||||
if (!this._strikeTargetAt(goal, civ)) return false;
|
||||
this._declareWar(civ, this._strikeTargetAt(goal, civ).civ);
|
||||
const destroyed = [];
|
||||
for (const unit of units) {
|
||||
const proto = this.unitProto(unit);
|
||||
const target = this._strikeTargetAt(goal, civ);
|
||||
if (target) {
|
||||
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
|
||||
target.hp -= damage;
|
||||
if (target.hp <= 0) {
|
||||
target.hp = 0;
|
||||
destroyed.push(target);
|
||||
}
|
||||
}
|
||||
unit.airHours = Math.max(0, (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) - (proto.strikeHours || 1));
|
||||
unit.autoReturn = false;
|
||||
}
|
||||
if (destroyed.length > 0) {
|
||||
this._recordMilitaryCasualties(destroyed);
|
||||
for (const unit of destroyed) this._destroyUnit(unit);
|
||||
this._visibilityDirty = true;
|
||||
}
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- training --
|
||||
|
||||
// Places a freshly trained unit: aircraft and land units appear in the city,
|
||||
// while a warship is launched onto the least crowded adjacent sea tile.
|
||||
_spawnTrainedUnit(city, proto) {
|
||||
return this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
|
||||
},
|
||||
|
||||
_spawnCoordsFor(city, proto) {
|
||||
const terrains = proto.traversableTerrains || [];
|
||||
const naval = terrains.length === 1 && terrains[0] === "Sea";
|
||||
if (!naval) return city.coords;
|
||||
let best = null;
|
||||
let bestCount = Infinity;
|
||||
for (const neighbour of this._neighbours(city.coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (!tile || tile.terrainClass !== "Sea") continue;
|
||||
const count = this.unitsAt(neighbour).length;
|
||||
if (count < bestCount) {
|
||||
bestCount = count;
|
||||
best = neighbour;
|
||||
}
|
||||
}
|
||||
return best || city.coords;
|
||||
},
|
||||
};
|
||||
@@ -199,7 +199,7 @@ export const combatMethods = {
|
||||
_resolveCaptures() {
|
||||
const claimed = new Set();
|
||||
for (const unit of this.units) {
|
||||
if (!this._isMilitary(unit)) continue;
|
||||
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
|
||||
const coords = unit.coords;
|
||||
if (this._hasHostileOn(coords, unit.civ)) continue;
|
||||
const city = this._cityByCoords.get(key(coords.x, coords.y));
|
||||
@@ -220,7 +220,7 @@ export const combatMethods = {
|
||||
_onUnitArrived(unit) {
|
||||
if (this._hasHostileOn(unit.coords, unit.civ)) return;
|
||||
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
|
||||
if (city && city.civ !== unit.civ && this._isMilitary(unit)) {
|
||||
if (city && city.civ !== unit.civ && this._isMilitary(unit) && !this._isAirUnit(unit)) {
|
||||
this._captureCity(city, unit.civ);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -536,7 +536,7 @@ export const economyMethods = {
|
||||
if (entry.kind === "building") {
|
||||
this._completeBuilding(city, entry);
|
||||
} else {
|
||||
this._spawnUnit(city.coords, city.civ, proto);
|
||||
this._spawnTrainedUnit(city, proto);
|
||||
this._visibilityDirty = true;
|
||||
}
|
||||
queue.shift();
|
||||
|
||||
@@ -126,24 +126,34 @@ export const movementMethods = {
|
||||
// joined on arrival; this only decides whether the march declares war.
|
||||
const target = this.enemyUnitsAt(goal, unit.civ).find((other) => this._isMilitary(other)) || null;
|
||||
const attacking = !!target && this._isMilitary(unit);
|
||||
const proto = this.unitProto(unit);
|
||||
const air = this._isAirProto(proto);
|
||||
const path = this.findPath(unit, unit.coords, goal, pathCache);
|
||||
if (path.length < 2) {
|
||||
return false;
|
||||
}
|
||||
// An aircraft may only be ordered inside its mission radius.
|
||||
if (air && path.length - 1 > (proto.missionRange || Infinity)) {
|
||||
return false;
|
||||
}
|
||||
// Marching a military unit into, or attacking, a country we are at peace
|
||||
// with declares war on it. The browser confirms the move first, but the
|
||||
// server enforces the rule so the two can never disagree.
|
||||
// server enforces the rule so the two can never disagree. Aircraft fly
|
||||
// over foreign land without provoking anyone; only a strike declares war.
|
||||
if (this._isMilitary(unit)) {
|
||||
if (attacking) this._declareWar(unit.civ, target.civ);
|
||||
for (const coords of path) {
|
||||
const owner = this.civAt(coords);
|
||||
if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
|
||||
if (!air) {
|
||||
for (const coords of path) {
|
||||
const owner = this.civAt(coords);
|
||||
if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
unit.path = path;
|
||||
unit.pathIndex = 0;
|
||||
unit.progressHours = 0;
|
||||
unit.moveSpeed = speed;
|
||||
unit.autoReturn = false;
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -183,7 +193,10 @@ export const movementMethods = {
|
||||
// Friendly occupants do not block either: the unit simply stacks.
|
||||
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
|
||||
const needed = cost - unit.progressHours;
|
||||
if (hoursLeft >= needed) {
|
||||
// A hair of slack lets a run of equal steps that sums to exactly the
|
||||
// tick's hours (for example ten 0.2-hour air steps) finish in one tick
|
||||
// instead of stalling on a floating-point remainder.
|
||||
if (hoursLeft + 1e-9 >= needed) {
|
||||
hoursLeft -= needed;
|
||||
unit.pathIndex += 1;
|
||||
unit.progressHours = 0;
|
||||
@@ -268,6 +281,11 @@ export const movementMethods = {
|
||||
},
|
||||
|
||||
_tileTravelHours(unit, coords) {
|
||||
// Aircraft fly over the ground: their speed is a flat tiles-per-hour figure,
|
||||
// untouched by terrain, roads or whose land lies below.
|
||||
if (this._isAirUnit(unit)) {
|
||||
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
|
||||
}
|
||||
// A road levels the terrain: every road tile costs the same to cross.
|
||||
const multiplier = this.roads.has(key(coords.x, coords.y))
|
||||
? ROADS.movementCostMultiplier
|
||||
@@ -311,9 +329,17 @@ export const movementMethods = {
|
||||
// to pass through: pathfinding routes around enemies instead of through them.
|
||||
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1) {
|
||||
const proto = this.unitProto(unit);
|
||||
if (!this.canEnter(unit.civ, proto, coords)) return false;
|
||||
const air = this._isAirProto(proto);
|
||||
if (air) {
|
||||
// Aircraft fly over fog and foreign lands alike, so only the tile's
|
||||
// existence and its terrain class constrain them.
|
||||
const tile = this.tiles[key(coords.x, coords.y)];
|
||||
if (!tile || !proto.traversableTerrains.includes(tile.terrainClass)) return false;
|
||||
} else if (!this.canEnter(unit.civ, proto, coords)) {
|
||||
return false;
|
||||
}
|
||||
const owner = this.civAt(coords);
|
||||
if (!isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
|
||||
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
|
||||
const occupants = this.unitsAt(coords).filter((other) => other !== unit);
|
||||
if (occupants.length > 0) {
|
||||
const hostile = occupants.filter((other) => other.civ !== unit.civ);
|
||||
@@ -325,6 +351,8 @@ export const movementMethods = {
|
||||
}
|
||||
const city = this._cityByCoords.get(key(coords.x, coords.y));
|
||||
if (city && city.civ !== unit.civ) {
|
||||
// Aircraft never occupy an enemy city; they attack from the air instead.
|
||||
if (air) return false;
|
||||
if (!isGoal) return false;
|
||||
if (!this._isMilitary(unit)) return false;
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ export const orderMethods = {
|
||||
if (!city) return false;
|
||||
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
|
||||
const proto = this.protoUnits[protoIndex];
|
||||
// Aircraft and warships need the right installation in the city first.
|
||||
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
|
||||
const queue = this.training.get(cityId);
|
||||
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
|
||||
// A free unit (the worker) needs neither money nor production time.
|
||||
if (proto.cost <= 0) {
|
||||
this._spawnUnit(city.coords, city.civ, proto);
|
||||
this._spawnTrainedUnit(city, proto);
|
||||
this._visibilityDirty = true;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
@@ -64,6 +66,9 @@ export const orderMethods = {
|
||||
const city = this.findCity(cityId);
|
||||
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
||||
const proto = this.protoBuildings[protoIndex];
|
||||
// Placement rules: a port needs the sea, a shipyard needs a port.
|
||||
if (proto.coastal && !this.isCoastalCity(city)) return false;
|
||||
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
|
||||
const queue = this.training.get(cityId);
|
||||
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
|
||||
const level =
|
||||
|
||||
@@ -87,6 +87,9 @@ export const serializationMethods = {
|
||||
coords: [city.coords.x, city.coords.y],
|
||||
name: city.name,
|
||||
isCapital: city.isCapital,
|
||||
// Whether a port may be raised here, so the browser can grey out the
|
||||
// order without regenerating the map.
|
||||
coastal: this.isCoastalCity(city),
|
||||
population: city.population,
|
||||
improvements: city.improvements,
|
||||
buildings: { ...city.buildings },
|
||||
@@ -116,6 +119,9 @@ export const serializationMethods = {
|
||||
hp: unit.hp,
|
||||
maxHp: unit.maxHp,
|
||||
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
|
||||
// Aircraft carry their endurance so the client can show the fuel left.
|
||||
airHours: unit.airHours,
|
||||
homeCityId: unit.homeCityId,
|
||||
path,
|
||||
pathIndex,
|
||||
progressHours: unit.progressHours,
|
||||
|
||||
@@ -42,7 +42,8 @@ export const statusMethods = {
|
||||
|
||||
_isNaval(unit) {
|
||||
const proto = this.unitProto(unit);
|
||||
return !!(proto && proto.traversableTerrains.includes("Sea"));
|
||||
// Aircraft may fly over the sea, but only a hull counts as naval supply.
|
||||
return !!(proto && !proto.air && proto.traversableTerrains.includes("Sea"));
|
||||
},
|
||||
|
||||
// Land connected components for one civ, with enemy-occupied and enemy-held
|
||||
|
||||
@@ -108,7 +108,7 @@ export const worldMethods = {
|
||||
this.roads = buildRoadNetwork(this.cities, this.topology, this.tiles);
|
||||
},
|
||||
|
||||
_spawnUnit(center, civIndex, proto) {
|
||||
_spawnUnit(center, civIndex, proto, homeCityId = null) {
|
||||
let index = this.protoUnits.indexOf(proto);
|
||||
if (index < 0) {
|
||||
this.protoUnits.push(proto);
|
||||
@@ -129,6 +129,12 @@ export const worldMethods = {
|
||||
invader: false,
|
||||
statuses: [],
|
||||
};
|
||||
if (proto.air) {
|
||||
unit.homeCityId = homeCityId;
|
||||
unit.airHours = proto.enduranceHours;
|
||||
unit.airborne = false;
|
||||
unit.autoReturn = false;
|
||||
}
|
||||
this.units.push(unit);
|
||||
this._unitById.set(this._nextUnitId, unit);
|
||||
this._nextUnitId += 1;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { parseKey } from "../shared/hex.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
function buildingIndex(state, id) {
|
||||
return state.protoBuildings.findIndex((building) => building.id === id);
|
||||
}
|
||||
|
||||
function protoIndex(state, id) {
|
||||
return state.protoUnits.findIndex((unit) => unit.id === id);
|
||||
}
|
||||
|
||||
function protoUnit(state, id) {
|
||||
return state.protoUnits.find((unit) => unit.id === id);
|
||||
}
|
||||
|
||||
function coastalCity(state, civ) {
|
||||
return state.cities.find((city) => city.civ === civ && state.isCoastalCity(city)) || null;
|
||||
}
|
||||
|
||||
function inlandCity(state, civ) {
|
||||
return state.cities.find((city) => city.civ === civ && !state.isCoastalCity(city)) || null;
|
||||
}
|
||||
|
||||
function giveBuilding(state, city, id, level = 1) {
|
||||
const index = buildingIndex(state, id);
|
||||
city.buildings[index] = level;
|
||||
state._gdpPerCapitaCache.clear();
|
||||
}
|
||||
|
||||
function firstLandNeighbour(state, coords) {
|
||||
return state.topology.neighbours(coords.x, coords.y).find((n) => state._isLand(n)) || null;
|
||||
}
|
||||
|
||||
function freeAdjacentLand(state) {
|
||||
for (const cell of state.landCells) {
|
||||
if (state.cityAt(cell) || state.unitAt(cell)) continue;
|
||||
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
|
||||
if (!state._isLand(neighbour) || state.cityAt(neighbour) || state.unitAt(neighbour)) continue;
|
||||
return [cell, neighbour];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function farTile(state, from, minDistance) {
|
||||
for (const k in state.tiles) {
|
||||
const coords = parseKey(k);
|
||||
if (state.topology.tileDistance(from, coords) <= minDistance) continue;
|
||||
if (state.cityAt(coords) || state.unitAt(coords)) continue;
|
||||
return coords;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class AirTest extends TestCase {
|
||||
test_port_can_only_be_raised_in_a_coastal_city() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const port = buildingIndex(state, "port");
|
||||
const inland = inlandCity(state, 0);
|
||||
const coastal = coastalCity(state, 0);
|
||||
this.assertNotNull(inland, "the fixture has an inland city");
|
||||
this.assertNotNull(coastal, "the fixture has a coastal city");
|
||||
this.assertFalse(state.requestBuild(inland.id, port), "an inland city cannot build a port");
|
||||
this.assertTrue(state.requestBuild(coastal.id, port), "a coastal city can build a port");
|
||||
}
|
||||
|
||||
test_shipyard_needs_a_coastal_city_and_a_port() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const shipyard = buildingIndex(state, "shipyard");
|
||||
const city = coastalCity(state, 0);
|
||||
this.assertFalse(state.requestBuild(city.id, shipyard), "no port, no shipyard");
|
||||
giveBuilding(state, city, "port");
|
||||
this.assertTrue(state.requestBuild(city.id, shipyard), "a port unlocks the shipyard");
|
||||
|
||||
// Even with a port, an inland city has no water to launch from.
|
||||
const inland = inlandCity(state, 0);
|
||||
this.assertNotNull(inland, "the fixture has an inland city");
|
||||
giveBuilding(state, inland, "port");
|
||||
this.assertFalse(state.requestBuild(inland.id, shipyard), "a shipyard is coastal-only");
|
||||
}
|
||||
|
||||
test_airport_and_shipyard_gate_training() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const city = coastalCity(state, 0);
|
||||
const jet = protoIndex(state, "jet_fighter");
|
||||
const battleship = protoIndex(state, "battleship");
|
||||
this.assertFalse(state.requestTrain(city.id, jet), "no airport, no aircraft");
|
||||
this.assertFalse(state.requestTrain(city.id, battleship), "no shipyard, no warship");
|
||||
giveBuilding(state, city, "airport");
|
||||
giveBuilding(state, city, "shipyard");
|
||||
this.assertTrue(state.requestTrain(city.id, jet), "the airport unlocks aircraft");
|
||||
this.assertTrue(state.requestTrain(city.id, battleship), "the shipyard unlocks warships");
|
||||
}
|
||||
|
||||
test_trained_aircraft_gets_its_home_airport() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const city = coastalCity(state, 0);
|
||||
giveBuilding(state, city, "airport");
|
||||
const jet = protoIndex(state, "jet_fighter");
|
||||
this.assertTrue(state.requestTrain(city.id, jet), "the airport lets the order through");
|
||||
for (let i = 0; i < 100000 && state.training.size > 0; i++) state.advanceHour();
|
||||
const unit = state.units.find((u) => u.proto === jet && u.civ === 0);
|
||||
this.assertNotNull(unit, "the aircraft was produced");
|
||||
this.assertEqual(unit.homeCityId, city.id, "it is based at the training city");
|
||||
this.assertFalse(unit.airborne, "it is parked on the runway");
|
||||
}
|
||||
|
||||
test_battleships_launch_onto_an_adjacent_sea_tile() {
|
||||
const state = smallState();
|
||||
const city = coastalCity(state, 0);
|
||||
const battleship = protoUnit(state, "battleship");
|
||||
const unit = state._spawnTrainedUnit(city, battleship);
|
||||
const tile = state.tiles[`${unit.coords.x},${unit.coords.y}`];
|
||||
this.assertEqual(tile.terrainClass, "Sea", "the hull is launched on water");
|
||||
this.assertTrue(
|
||||
state.topology.tileDistance(unit.coords, city.coords) === 1,
|
||||
"the launch tile neighbours the city"
|
||||
);
|
||||
}
|
||||
|
||||
test_aircraft_fly_home_and_refuel_after_a_sortie() {
|
||||
const state = smallState();
|
||||
const city = coastalCity(state, 0);
|
||||
giveBuilding(state, city, "airport");
|
||||
const jet = protoUnit(state, "jet_fighter");
|
||||
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
|
||||
const goal = firstLandNeighbour(state, city.coords);
|
||||
this.assertNotNull(goal, "there is somewhere to fly");
|
||||
this.assertTrue(state.requestGroupMove([unit.id], goal), "the sortie is accepted");
|
||||
|
||||
state.advanceHour();
|
||||
this.assertEqual(unit.coords.x, goal.x, "it reached the target");
|
||||
this.assertTrue(unit.airborne, "it is airborne away from the airport");
|
||||
this.assertLess(unit.airHours, jet.enduranceHours, "the sortie burned fuel");
|
||||
|
||||
state.advanceHour();
|
||||
this.assertEqual(unit.coords.x, city.coords.x, "it returned to its airport");
|
||||
this.assertEqual(unit.coords.y, city.coords.y);
|
||||
this.assertFalse(unit.airborne, "it is parked again");
|
||||
this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled");
|
||||
}
|
||||
|
||||
test_aircraft_crash_when_no_airport_can_reach_them() {
|
||||
const state = smallState();
|
||||
const jet = protoUnit(state, "jet_fighter");
|
||||
const spot = freeAdjacentLand(state);
|
||||
const unit = state._spawnUnit(spot[0], 0, jet, null);
|
||||
this.assertNull(state._nearestFriendlyAirport(unit), "no airport exists yet");
|
||||
state.advanceHour();
|
||||
this.assertNotNull(state.findUnit(unit.id), "still flying after one hour");
|
||||
state.advanceHour();
|
||||
this.assertNull(state.findUnit(unit.id), "out of fuel, it crashed");
|
||||
}
|
||||
|
||||
test_aircraft_cross_every_terrain_at_their_listed_speed() {
|
||||
const state = smallState();
|
||||
const jet = protoUnit(state, "jet_fighter");
|
||||
const unit = state._spawnUnit(state.landCells[0], 0, jet, null);
|
||||
const expected = 1 / jet.speed;
|
||||
let checked = 0;
|
||||
for (const k in state.tiles) {
|
||||
this.assertApprox(
|
||||
state._tileTravelHours(unit, parseKey(k)),
|
||||
expected,
|
||||
1e-9,
|
||||
`flat flight time over ${state.tiles[k].terrainType}`
|
||||
);
|
||||
checked += 1;
|
||||
if (checked >= 12) break;
|
||||
}
|
||||
this.assertGreater(checked, 0);
|
||||
}
|
||||
|
||||
test_aircraft_reach_the_edge_of_their_radius_and_return() {
|
||||
const state = smallState();
|
||||
const city = coastalCity(state, 0);
|
||||
giveBuilding(state, city, "airport");
|
||||
const jet = protoUnit(state, "jet_fighter");
|
||||
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
|
||||
let goal = null;
|
||||
for (const k in state.tiles) {
|
||||
const coords = parseKey(k);
|
||||
if (state.topology.tileDistance(city.coords, coords) !== jet.missionRange) continue;
|
||||
if (state.cityAt(coords) || state.unitAt(coords)) continue;
|
||||
goal = coords;
|
||||
break;
|
||||
}
|
||||
this.assertNotNull(goal, "a tile sits on the radius edge");
|
||||
this.assertTrue(state.requestGroupMove([unit.id], goal), "the edge is reachable");
|
||||
state.advanceHour();
|
||||
this.assertEqual(unit.coords.x, goal.x, "it reached the edge");
|
||||
this.assertEqual(unit.coords.y, goal.y);
|
||||
state.advanceHour();
|
||||
this.assertEqual(unit.coords.x, city.coords.x, "the round trip closed");
|
||||
this.assertEqual(unit.coords.y, city.coords.y);
|
||||
this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it landed and refuelled");
|
||||
}
|
||||
|
||||
test_aircraft_cannot_be_ordered_beyond_their_range() {
|
||||
const state = smallState();
|
||||
const city = coastalCity(state, 0);
|
||||
giveBuilding(state, city, "airport");
|
||||
const jet = protoUnit(state, "jet_fighter");
|
||||
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
|
||||
const target = farTile(state, city.coords, jet.missionRange);
|
||||
this.assertNotNull(target, "the map has tiles beyond the radius");
|
||||
this.assertFalse(state.requestGroupMove([unit.id], target), "the order is refused");
|
||||
this.assertEmpty(unit.path, "no route was stored");
|
||||
}
|
||||
|
||||
test_bombers_strike_from_afar_without_joining_the_battle() {
|
||||
const state = smallState();
|
||||
const bomber = protoUnit(state, "bomber");
|
||||
const infantry = protoUnit(state, "modern_infantry");
|
||||
const pair = freeAdjacentLand(state);
|
||||
const attacker = state._spawnUnit(pair[0], 0, bomber, null);
|
||||
const defender = state._spawnUnit(pair[1], 1, infantry);
|
||||
this.assertTrue(state.requestStrike([attacker.id], defender.coords), "the strike is accepted");
|
||||
this.assertLess(defender.hp, defender.maxHp, "the bomb damaged the target");
|
||||
this.assertEqual(attacker.coords.x, pair[0].x, "the bomber never moved");
|
||||
this.assertEqual(attacker.coords.y, pair[0].y);
|
||||
this.assertEmpty(state._battleTiles(), "no melee battle was started");
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export class DataTest extends TestCase {
|
||||
for (const building of BUILDINGS) {
|
||||
this.assertNotEmpty(building.effects, `${building.id} effects`);
|
||||
this.assertGreater(building.costAlpha, 1, `${building.id} cost growth`);
|
||||
this.assertTrue(building.icon && building.icon.endsWith(".svg"), `${building.id} icon`);
|
||||
for (const effect of building.effects) {
|
||||
this.assertTrue(effect.stat.length > 0);
|
||||
this.assertGreater(effect.baseValue, 0);
|
||||
@@ -113,6 +114,37 @@ export class DataTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
test_ports_airports_and_shipyards_declare_their_requirements() {
|
||||
const port = buildingById("port");
|
||||
const airport = buildingById("airport");
|
||||
const shipyard = buildingById("shipyard");
|
||||
this.assertTrue(port.coastal, "a port needs the coast");
|
||||
this.assertNull(port.requiresBuilding, "a port stands on its own");
|
||||
this.assertTrue(shipyard.requiresBuilding === "port", "a shipyard needs a port");
|
||||
this.assertTrue(shipyard.coastal, "a shipyard needs the coast too");
|
||||
this.assertNull(airport.requiresBuilding, "an airport stands on its own");
|
||||
this.assertTrue(port.icon.endsWith(".svg"));
|
||||
this.assertTrue(airport.icon.endsWith(".svg"));
|
||||
this.assertTrue(shipyard.icon.endsWith(".svg"));
|
||||
}
|
||||
|
||||
test_aircraft_and_warships_are_gated_and_fuelled() {
|
||||
for (const id of ["jet_fighter", "bomber"]) {
|
||||
const plane = protoUnitById(id);
|
||||
this.assertTrue(plane.air, `${id} is an aircraft`);
|
||||
this.assertEqual(plane.requiresBuilding, "airport", `${id} needs an airport`);
|
||||
this.assertGreater(plane.speed, 1, `${id} is fast`);
|
||||
this.assertGreater(plane.enduranceHours, 0, `${id} has endurance`);
|
||||
this.assertGreater(plane.missionRange, 0, `${id} has a mission radius`);
|
||||
this.assertTrue(plane.icon.endsWith(".svg"));
|
||||
}
|
||||
this.assertTrue(protoUnitById("bomber").strike, "the bomber strikes from afar");
|
||||
const battleship = protoUnitById("battleship");
|
||||
this.assertEqual(battleship.requiresBuilding, "shipyard");
|
||||
this.assertEqual(battleship.traversableTerrains, ["Sea"]);
|
||||
this.assertTrue(battleship.icon.endsWith(".svg"));
|
||||
}
|
||||
|
||||
test_effect_value_scales_with_level() {
|
||||
const effect = { baseValue: 10, exponent: 1 };
|
||||
this.assertApprox(effectValue(effect, 1), 10);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { GameScreen } from "../client/js/game_screen.js";
|
||||
import { CIVILISATIONS } from "../shared/data.js";
|
||||
import { BUILDINGS, CIVILISATIONS } from "../shared/data.js";
|
||||
import { defaultState, adjacentLand } from "./framework/helpers.js";
|
||||
|
||||
function stubNetwork() {
|
||||
@@ -101,7 +101,7 @@ export class GameScreenTest extends TestCase {
|
||||
screen.map.onCitySelected(capital.id);
|
||||
env.$("#city-manage").click();
|
||||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||||
this.assertSize(env.$("#city-buildings-list .row-card"), 6);
|
||||
this.assertSize(env.$("#city-buildings-list .row-card"), BUILDINGS.length);
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_t
|
||||
import { WebSocketProtocolTest } from "./websocket_test.js";
|
||||
import { NetTest } from "./net_test.js";
|
||||
import { MapViewTest } from "./map_view_test.js";
|
||||
import { AirTest } from "./air_test.js";
|
||||
import { CityModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
|
||||
import { GameScreenTest } from "./game_screen_test.js";
|
||||
import { GameFeedTest } from "./feed_test.js";
|
||||
@@ -82,6 +83,7 @@ const SUITES = [
|
||||
WebSocketProtocolTest,
|
||||
NetTest,
|
||||
MapViewTest,
|
||||
AirTest,
|
||||
CityModalTest,
|
||||
NationModalTest,
|
||||
ConfirmModalTest,
|
||||
|
||||