├── .gitignore ├── LICENSE.md ├── README.md ├── build.py ├── build.sh ├── icons ├── README.md ├── menu.svg ├── music.svg └── undo.svg ├── out ├── app.css ├── app.json └── index.html ├── package.json ├── pictures ├── crimson_crown.png ├── crimson_crown_320.png ├── crimson_crown_320_dithering.png ├── crimson_crown_800_500.png └── crimson_crown_800_500_dithering.png ├── pieces ├── README.md ├── bB.svg ├── bK.svg ├── bN.svg ├── bP.svg ├── bQ.svg ├── bR.svg ├── wB.svg ├── wK.svg ├── wN.svg ├── wP.svg ├── wQ.svg └── wR.svg ├── scripts ├── debug.py ├── load_icons.py └── load_pieces.py ├── tsconfig.json └── typescript ├── app.ts ├── audio ├── ImpulseResponse.ts ├── audio.ts └── song.ts ├── debug.ts ├── definitions.ts ├── icons.ts ├── pieces.ts ├── rendering.ts └── share.ts /.gitignore: -------------------------------------------------------------------------------- 1 | bun.lockb 2 | node_modules/ 3 | out/**/*.js 4 | build/ 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Proprietary Software License Agreement 2 | 3 | ## Grant of License 4 | 5 | This License Agreement ("Agreement") is a legal agreement between you ("User") and [Mark Vasilkov](https://github.com/mvasilkov) ("Licensor") for the software product identified as [King Thirteen](https://github.com/mvasilkov/board2024) ("Software"). By downloading, installing, or using the Software, you agree to be bound by the terms of this Agreement. 6 | 7 | The Licensor grants the User a limited, non-exclusive, non-transferable, and revocable license to use the Software for personal purposes only, subject to the terms and conditions outlined in this Agreement. 8 | 9 | ## Ownership and Restrictions 10 | 11 | The Software is proprietary to the Licensor and is protected by copyright and other intellectual property laws. All rights, title, and interest in and to the Software, including any modifications or derivative works, are and will remain the exclusive property of the Licensor. 12 | 13 | The User may not: 14 | 15 | * Sell, resell, lease, rent, sublicense, or distribute the Software in any form. 16 | * Use the Software for commercial purposes or for the purpose of generating profit. 17 | * Modify, reverse engineer, decompile, disassemble, or create derivative works of the Software, except as allowed by applicable law. 18 | * Transfer, assign, or sublicense the rights granted under this Agreement without prior written consent from the Licensor. 19 | 20 | ## No Warranty 21 | 22 | The Software is provided "as is," and the Licensor makes no warranties, whether express or implied, regarding the Software’s functionality, performance, or suitability for a particular purpose. The Licensor does not warrant that the Software will be error-free or that it will meet the User’s requirements. 23 | 24 | ## Limitation of Liability 25 | 26 | To the fullest extent permitted by law, in no event will the Licensor be liable for any indirect, incidental, special, consequential, or punitive damages arising out of or related to the use or inability to use the Software, even if the Licensor has been advised of the possibility of such damages. 27 | 28 | ## Termination 29 | 30 | This Agreement is effective until terminated. The Licensor may terminate this Agreement at any time if the User breaches any provision of this Agreement. Upon termination, the User must cease all use of the Software and delete any copies in their possession or control. 31 | 32 | ## Entire Agreement 33 | 34 | This Agreement constitutes the entire understanding between the parties regarding the Software and supersedes all prior agreements, discussions, or representations related to the Software. 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # King Thirteen 2 | 3 | 4 | 5 | King Godric XIII, called the Crimson Hand, the Withering Flame, the dreaded King Thirteen rose from his throne. 6 | 7 | The whispers of his subjects had grown too loud, too bold. They had forgotten their place. 8 | 9 | — *Silence!* 10 | 11 | Clad in blood-red armor, Godric towered over them, an enormous and imposing figure. 12 | 13 | — *I am fear incarnate!* — his roar echoed through the hall. — *You will bow before me, or you will burn!* 14 | 15 | Soon after, swords were drawn, and scarlet splattered the floor. 16 | 17 | --- 18 | 19 | **How to play:** 20 | 21 | * Join forces 22 | * Claim the throne 23 | * Revel in glory 24 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | from os.path import abspath 4 | from pathlib import Path 5 | import sys 6 | 7 | OUR_ROOT = Path(abspath(__file__)).parent 8 | 9 | NATLIB_LICENSE = ''' 10 | /** This file is part of natlib. 11 | * https://github.com/mvasilkov/natlib 12 | * @license MIT | Copyright (c) 2022, 2023, 2024 Mark Vasilkov 13 | */ 14 | 'use strict' 15 | '''.strip() 16 | 17 | FILE_LICENSE = ''' 18 | /** This file is part of King Thirteen. 19 | * https://github.com/mvasilkov/board2024 20 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 21 | */ 22 | 'use strict' 23 | '''.strip() 24 | 25 | OUT_FILE = f''' 26 | {FILE_LICENSE} 27 | 28 | export const value = %s 29 | export const width = %d 30 | export const height = %d 31 | export const cardinality = %d 32 | export const palette = %s 33 | '''.lstrip() 34 | 35 | BUILD_DIR = OUR_ROOT / 'build' 36 | 37 | HTML_LINK_CSS = '' 38 | HTML_INLINE_CSS = '' 39 | HTML_LINK_JS = '' 40 | HTML_INLINE_JS = '' 41 | 42 | MANIFEST_IN = OUR_ROOT / 'out' / 'app.json' 43 | MANIFEST_OUT = BUILD_DIR / 'app.json' 44 | 45 | 46 | def build_inline(): 47 | index = (BUILD_DIR / 'index.html').read_text(encoding='utf-8') 48 | app_css = (BUILD_DIR / 'app.opt.css').read_text(encoding='utf-8') 49 | app_js = (BUILD_DIR / 'app.opt.js').read_text(encoding='utf-8') 50 | 51 | app_js = app_js.replace('') 85 | print('To rebuild the entire thing, run `build.sh` instead.') 86 | sys.exit(-1) 87 | 88 | match sys.argv[1]: 89 | case 'inline': 90 | build_inline() 91 | case 'validate': 92 | build_validate() 93 | case 'manifest': 94 | build_manifest() 95 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | # outDir 5 | # brew install jq 6 | out_dir=$(jq -r .compilerOptions.outDir tsconfig.json) 7 | 8 | # Clean 9 | git clean -fdx $out_dir 10 | rm -rf build 11 | mkdir build 12 | 13 | # Build 14 | node_modules/.bin/tsc 15 | 16 | # Validate 17 | python3 build.py validate 18 | 19 | # Bundle 20 | node_modules/.bin/rollup -f iife -o build/app.js --no-freeze $out_dir/app.js 21 | 22 | # Optimize 23 | node_modules/.bin/terser -cm --mangle-props only_annotated -o build/app.opt.js --comments false build/app.js 24 | node_modules/.bin/cleancss -O1 -o build/app.opt.css $out_dir/app.css 25 | 26 | cat <build/options.json 27 | { 28 | "collapseWhitespace": true, 29 | "removeAttributeQuotes": true, 30 | "removeComments": true 31 | } 32 | END 33 | node_modules/.bin/html-minifier-terser -c build/options.json -o build/index.html $out_dir/index.html 34 | 35 | python3 build.py manifest 36 | 37 | # Package 38 | python3 build.py inline 39 | zip -jX9 build/app.zip build/index.html build/app.json 40 | # brew install advancecomp 41 | advzip -z4 build/app.zip 42 | # https://github.com/fhanau/Efficient-Compression-Tool 43 | ect -10009 -zip build/app.zip 44 | 45 | echo Final package size: 46 | wc -c build/app.zip 47 | -------------------------------------------------------------------------------- /icons/README.md: -------------------------------------------------------------------------------- 1 | [Phosphor Icons](https://github.com/phosphor-icons/core) 2 | 3 | [MIT License](https://github.com/phosphor-icons/core/blob/main/LICENSE) 4 | -------------------------------------------------------------------------------- /icons/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/music.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /out/app.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | * { 4 | cursor: default; 5 | touch-action: manipulation; 6 | -webkit-user-drag: none; 7 | } 8 | 9 | html, 10 | body { 11 | overscroll-behavior: none; 12 | -webkit-user-select: none; 13 | user-select: none; 14 | } 15 | 16 | body { 17 | display: flex; 18 | flex-wrap: wrap; 19 | align-content: center; 20 | justify-content: center; 21 | margin: 0; 22 | min-height: 100vh; 23 | background: #313638; 24 | font-family: -apple-system, 'Segoe UI', 'DejaVu Sans', system-ui, sans-serif; 25 | } 26 | 27 | /* Board */ 28 | .b { 29 | display: flex; 30 | flex-wrap: wrap; 31 | align-content: space-between; 32 | justify-content: space-between; 33 | width: 90vmin; 34 | height: 90vmin; 35 | } 36 | 37 | /* Cell */ 38 | .c { 39 | display: flex; 40 | flex-wrap: wrap; 41 | align-content: center; 42 | justify-content: center; 43 | width: 24.75%; 44 | height: 24.75%; 45 | } 46 | 47 | /* Light */ 48 | .c { 49 | background: #d49577; 50 | box-shadow: 0 1vmin #845750, 0 1vmin 0 0.3333vmin #00000040; 51 | border-radius: 8.3333%; 52 | position: relative; 53 | transition: transform 0.05s; 54 | } 55 | 56 | .c:active { 57 | transform: translateY(0.5vmin); 58 | } 59 | 60 | /* Dark */ 61 | .c:nth-child(8n+2), 62 | .c:nth-child(8n+4), 63 | .c:nth-child(8n+5), 64 | .c:nth-child(8n+7) { 65 | background: #9f705a; 66 | box-shadow: 0 1vmin #633b3f, 0 1vmin 0 0.3333vmin #00000040; 67 | } 68 | 69 | /* Move available */ 70 | .a::after { 71 | content: ''; 72 | position: absolute; 73 | top: 1.5vmin; 74 | right: 1.5vmin; 75 | height: 3vmin; 76 | width: 3vmin; 77 | background: #f4f4f4; 78 | border-radius: 100%; 79 | } 80 | 81 | /* Piece */ 82 | .p { 83 | display: flex; 84 | flex-wrap: wrap; 85 | align-content: end; 86 | justify-content: center; 87 | width: 19vmin; 88 | height: 19vmin; 89 | z-index: 1; 90 | } 91 | 92 | .p.an { 93 | z-index: 3; 94 | } 95 | 96 | svg { 97 | position: absolute; 98 | width: 19vmin; 99 | height: 19vmin; 100 | } 101 | 102 | /* Value */ 103 | .n { 104 | -webkit-backdrop-filter: blur(0.5vmin); 105 | backdrop-filter: blur(0.5vmin); 106 | border-radius: 1.6vmin; 107 | padding: 0 1.4vmin; 108 | font-size: 5vmin; 109 | z-index: 2; 110 | } 111 | 112 | .p.an .n { 113 | -webkit-backdrop-filter: none; 114 | backdrop-filter: none; 115 | z-index: 4; 116 | } 117 | 118 | /* King value */ 119 | .p.ps5 { 120 | align-content: center; 121 | } 122 | 123 | .p.ps5 .n { 124 | -webkit-backdrop-filter: none; 125 | backdrop-filter: none; 126 | font-family: Georgia, 'Noto Serif', serif; 127 | font-size: 4vmin; 128 | letter-spacing: -0.1vmin; 129 | position: relative; 130 | top: 1.6vmin; 131 | } 132 | 133 | /* Selected */ 134 | .st { 135 | fill: none; 136 | stroke: none; 137 | } 138 | 139 | .s .p .st { 140 | stroke: #f4f4f4; 141 | } 142 | 143 | .s .p:not(.an) svg { 144 | animation: 1.6s cubic-bezier(0.45, 0, 0.55, 1) infinite alternate se; 145 | } 146 | 147 | /* Selected king */ 148 | .s .p.ps5 { 149 | animation: 0.5s ease-in-out no; 150 | } 151 | 152 | .s .p.ps5 .st { 153 | stroke: none; 154 | } 155 | 156 | .s .p.ps5 svg { 157 | animation: none; 158 | } 159 | 160 | /* Spawn */ 161 | @keyframes sp { 162 | 0% { 163 | transform: scale(0.1); 164 | } 165 | 166 | 100% { 167 | transform: scale(1); 168 | } 169 | } 170 | 171 | /* Selected */ 172 | @keyframes se { 173 | 0% { 174 | transform: translateY(0.5vmin); 175 | } 176 | 177 | 100% { 178 | transform: translateY(-0.5vmin); 179 | } 180 | } 181 | 182 | /* Decline */ 183 | @keyframes no { 184 | 0% { 185 | transform: translateX(0); 186 | } 187 | 188 | 20% { 189 | transform: translateX(-1.2vmin); 190 | } 191 | 192 | 40% { 193 | transform: translateX(1.2vmin); 194 | } 195 | 196 | 60% { 197 | transform: translateX(-1vmin); 198 | } 199 | 200 | 80% { 201 | transform: translateX(1vmin); 202 | } 203 | 204 | 100% { 205 | transform: translateX(0); 206 | } 207 | } 208 | 209 | /* Screen shake */ 210 | .sh { 211 | animation: 0.2s ease-in-out 0.1s sha; 212 | } 213 | 214 | @keyframes sha { 215 | 0% { 216 | transform: translate(0, 0); 217 | } 218 | 219 | 20% { 220 | transform: translate(1.2vmin, -0.4vmin); 221 | } 222 | 223 | 40% { 224 | transform: translate(-0.9vmin, 0.3vmin); 225 | } 226 | 227 | 60% { 228 | transform: translate(0.6vmin, -0.2vmin); 229 | } 230 | 231 | 80% { 232 | transform: translate(-0.3vmin, 0.1vmin); 233 | } 234 | 235 | 100% { 236 | transform: translate(0, 0); 237 | } 238 | } 239 | 240 | /* Menu */ 241 | .u { 242 | display: flex; 243 | flex-direction: column; 244 | flex-wrap: wrap; 245 | align-content: space-around; 246 | justify-content: center; 247 | align-self: center; 248 | position: absolute; 249 | width: 100vw; 250 | height: 100vh; 251 | z-index: 10; 252 | transition: opacity 0.25s cubic-bezier(0.5, 1, 0.89, 1), transform 0.25s cubic-bezier(0.5, 1, 0.89, 1); 253 | } 254 | 255 | /* Hidden */ 256 | .h { 257 | opacity: 0; 258 | pointer-events: none; 259 | transform: translateY(-100vh); 260 | transition: opacity 0.25s cubic-bezier(0.11, 0, 0.5, 0), transform 0.25s cubic-bezier(0.11, 0, 0.5, 0); 261 | } 262 | 263 | @media (orientation: portrait) { 264 | .h { 265 | transform: translateX(-100vw); 266 | } 267 | } 268 | 269 | /* Title */ 270 | .ti { 271 | display: flex; 272 | flex-wrap: wrap; 273 | align-content: center; 274 | justify-content: center; 275 | height: 22vmin; 276 | min-width: 64vmin; 277 | font-size: 9vmin; 278 | font-weight: 300; 279 | -webkit-backdrop-filter: blur(0.5vmin); 280 | backdrop-filter: blur(0.5vmin); 281 | background: #17001de0; 282 | border-radius: 1.86vmin 1.86vmin 0 0; 283 | color: #ef0c45; 284 | } 285 | 286 | .sc { 287 | height: 16vmin; 288 | font-size: 7vmin; 289 | border-radius: 0; 290 | user-select: text; 291 | } 292 | 293 | /* Button */ 294 | .bu { 295 | display: flex; 296 | flex-wrap: wrap; 297 | align-content: center; 298 | justify-content: center; 299 | height: 16vmin; 300 | min-width: 64vmin; 301 | font-size: 7vmin; 302 | font-weight: 300; 303 | -webkit-backdrop-filter: blur(0.5vmin); 304 | backdrop-filter: blur(0.5vmin); 305 | background: #17001dc0; 306 | color: #fec070; 307 | } 308 | 309 | .an .ti, 310 | .an .bu { 311 | -webkit-backdrop-filter: none; 312 | backdrop-filter: none; 313 | } 314 | 315 | .bu:not(:last-child) { 316 | border-bottom: 0.5vmin solid #17001de0; 317 | } 318 | 319 | .bu:last-child { 320 | border-radius: 0 0 1.86vmin 1.86vmin; 321 | } 322 | 323 | .bu:hover { 324 | background: #270022c0; 325 | } 326 | 327 | /* Toolbar */ 328 | .to { 329 | display: flex; 330 | gap: 1vmin; 331 | padding: 0.5vmin; 332 | position: fixed; 333 | top: 0; 334 | right: 0; 335 | z-index: 20; 336 | } 337 | 338 | @media (orientation: portrait) { 339 | .to { 340 | flex-direction: row-reverse; 341 | } 342 | } 343 | 344 | @media (orientation: landscape) { 345 | .to { 346 | flex-direction: column; 347 | } 348 | } 349 | 350 | /* Toolbar button */ 351 | .tb { 352 | width: 6vmin; 353 | height: 6vmin; 354 | padding: 0.5vmin; 355 | border-radius: 8.3333%; 356 | position: relative; 357 | } 358 | 359 | .tb:hover { 360 | background: #00000020; 361 | } 362 | 363 | .tb svg { 364 | width: 6vmin; 365 | height: 6vmin; 366 | color: #dbcfb1; 367 | } 368 | 369 | .of svg { 370 | opacity: 0.4; 371 | } 372 | 373 | .of::after { 374 | content: ''; 375 | position: absolute; 376 | top: 2.5vmin; 377 | left: 0.5vmin; 378 | height: 0.4vmin; 379 | width: 6vmin; 380 | background: #dbcfb1; 381 | border-radius: 0.5vmin; 382 | opacity: 0.4; 383 | } 384 | -------------------------------------------------------------------------------- /out/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "King Thirteen", 3 | "display": "fullscreen", 4 | "background_color": "#21272a" 5 | } 6 | -------------------------------------------------------------------------------- /out/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | King Thirteen 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 |
32 |
King Thirteen
33 |
CONTINUE
34 |
NEW GAME
35 |
MUSIC: ON
36 |
37 | 38 |
39 |
DEFEAT
40 |
Score: 0
41 |
SHARE ON X
42 |
NEW GAME
43 |
44 | 45 |
46 |
47 |
48 |
49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "natlib": "latest" 4 | }, 5 | "devDependencies": { 6 | "clean-css-cli": "^5.6.3", 7 | "html-minifier-terser": "^7.2.0", 8 | "rollup": "^4.21.2", 9 | "terser": "^5.32.0", 10 | "typescript": "latest" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pictures/crimson_crown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/king-thirteen/ad268de8f8fca49678efaabbe031a4ca7a1eeb91/pictures/crimson_crown.png -------------------------------------------------------------------------------- /pictures/crimson_crown_320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/king-thirteen/ad268de8f8fca49678efaabbe031a4ca7a1eeb91/pictures/crimson_crown_320.png -------------------------------------------------------------------------------- /pictures/crimson_crown_320_dithering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/king-thirteen/ad268de8f8fca49678efaabbe031a4ca7a1eeb91/pictures/crimson_crown_320_dithering.png -------------------------------------------------------------------------------- /pictures/crimson_crown_800_500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/king-thirteen/ad268de8f8fca49678efaabbe031a4ca7a1eeb91/pictures/crimson_crown_800_500.png -------------------------------------------------------------------------------- /pictures/crimson_crown_800_500_dithering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/js13kGames/king-thirteen/ad268de8f8fca49678efaabbe031a4ca7a1eeb91/pictures/crimson_crown_800_500_dithering.png -------------------------------------------------------------------------------- /pieces/README.md: -------------------------------------------------------------------------------- 1 | Author: [sadsnake1](https://github.com/sadsnake1) 2 | 3 | License: [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 4 | 5 | See [here](https://github.com/lichess-org/lila/blob/master/COPYING.md) 6 | -------------------------------------------------------------------------------- /pieces/bB.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/bK.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/bN.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/bP.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/bQ.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/bR.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/wB.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/wK.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/wN.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/wP.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/wQ.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pieces/wR.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/debug.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from io import StringIO 4 | 5 | from but.scripts.batch import run_script 6 | 7 | script = ''' 8 | --persistent tsc -- --watch --preserveWatchOutput 9 | serve_static -h 127.0.0.1 10 | ''' 11 | 12 | if __name__ == '__main__': 13 | script_file = StringIO(script) 14 | script_file.name = 'debug.py' 15 | run_script(script_file) 16 | -------------------------------------------------------------------------------- /scripts/load_icons.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import re 5 | 6 | ts_start = ''' 7 | 'use strict' 8 | '''.lstrip() 9 | 10 | ts_function = ''' 11 | export const %sSVG = 12 | `%s` 13 | '''.lstrip() 14 | 15 | 16 | def replace_svg(svg: str) -> str: 17 | return svg.replace(' width="1em" height="1em"', '', 1) 18 | 19 | 20 | def load_icons(): 21 | parent_dir = Path(__file__).parents[1].resolve() 22 | 23 | menu_svg_file = parent_dir / 'icons' / 'menu.svg' 24 | menu_svg = menu_svg_file.read_text(encoding='utf-8') 25 | 26 | music_svg_file = parent_dir / 'icons' / 'music.svg' 27 | music_svg = music_svg_file.read_text(encoding='utf-8') 28 | 29 | undo_svg_file = parent_dir / 'icons' / 'undo.svg' 30 | undo_svg = undo_svg_file.read_text(encoding='utf-8') 31 | 32 | assert '`' not in menu_svg 33 | menu_fn = ts_function % ('menu', replace_svg(menu_svg)) 34 | 35 | assert '`' not in music_svg 36 | music_fn = ts_function % ('music', replace_svg(music_svg)) 37 | 38 | assert '`' not in undo_svg 39 | undo_fn = ts_function % ('undo', replace_svg(undo_svg)) 40 | 41 | outfile = parent_dir / 'typescript' / 'icons.ts' 42 | outfile_text = '\n'.join([ts_start, menu_fn, music_fn, undo_fn]) 43 | outfile.write_text(outfile_text, encoding='utf-8', newline='\n') 44 | 45 | 46 | if __name__ == '__main__': 47 | load_icons() 48 | -------------------------------------------------------------------------------- /scripts/load_pieces.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import re 5 | 6 | ts_start = ''' 7 | 'use strict' 8 | '''.lstrip() 9 | 10 | ts_function = ''' 11 | export const %sSVG = (color: string, outline: string, highlight: string, lowlight: string, _lowlight2: string) => 12 | `%s` 13 | '''.lstrip() 14 | 15 | 16 | def replace_colors(svg: str) -> str: 17 | svg_opening_pos = re.match('', svg).end() 18 | svg_closing_pos = svg.rfind('') 19 | 20 | if not re.match('' + svg[svg_closing_pos:] 22 | svg = svg[:svg_opening_pos] + '' + svg[svg_opening_pos:] 23 | 24 | result = ( 25 | svg.replace(' width="50mm"', '') 26 | .replace(' height="50mm"', '') 27 | .replace('fill="#e9e9e9"', 'fill="${color}"') 28 | .replace('stroke="#2a2a2a"', 'stroke="${outline}"') 29 | .replace('fill="#fff"', 'fill="${highlight}" style="mix-blend-mode:lighten"') 30 | .replace('opacity=".2" stroke="#000"', 'fill="${lowlight}" stroke="${lowlight}" style="mix-blend-mode:darken"') 31 | .replace('opacity=".2"', 'fill="${lowlight}" style="mix-blend-mode:darken"') 32 | .replace('fill="#010101" opacity=".25"', 'fill="${lowlight}" style="mix-blend-mode:darken"') 33 | .replace('opacity=".5"', 'fill="${_lowlight2}" style="mix-blend-mode:darken"') 34 | .replace('stroke-width="1.5"', 'stroke-width="1.3"') 35 | # matrix(.91877 0 0 .93482 -3036.3 1998.5) 36 | .replace('stroke-width="1.6185"', 'stroke-width="1.4027"') 37 | # matrix(.98734 0 0 .96296 -3412.8 2056) 38 | .replace('stroke-width="1.538"', 'stroke-width="1.3331"') 39 | # matrix(.96234 0 0 .9617 -3133.3 2054.9) 40 | .replace('stroke-width="1.559"', 'stroke-width="1.3513"') 41 | ) 42 | return result 43 | 44 | 45 | def load_pieces(): 46 | parent_dir = Path(__file__).parents[1].resolve() 47 | 48 | knight_svg_file = parent_dir / 'pieces' / 'wN.svg' 49 | knight_svg = knight_svg_file.read_text(encoding='utf-8') 50 | 51 | bishop_svg_file = parent_dir / 'pieces' / 'wB.svg' 52 | bishop_svg = bishop_svg_file.read_text(encoding='utf-8') 53 | 54 | rook_svg_file = parent_dir / 'pieces' / 'wR.svg' 55 | rook_svg = rook_svg_file.read_text(encoding='utf-8') 56 | 57 | queen_svg_file = parent_dir / 'pieces' / 'wQ.svg' 58 | queen_svg = queen_svg_file.read_text(encoding='utf-8') 59 | 60 | king_svg_file = parent_dir / 'pieces' / 'wK.svg' 61 | king_svg = king_svg_file.read_text(encoding='utf-8') 62 | 63 | assert '`' not in knight_svg 64 | knight_fn = ts_function % ('knight', replace_colors(knight_svg)) 65 | 66 | assert '`' not in bishop_svg 67 | bishop_fn = ts_function % ('bishop', replace_colors(bishop_svg)) 68 | 69 | assert '`' not in rook_svg 70 | rook_fn = ts_function % ('rook', replace_colors(rook_svg)) 71 | 72 | assert '`' not in queen_svg 73 | queen_fn = ts_function % ('queen', replace_colors(queen_svg)) 74 | 75 | assert '`' not in king_svg 76 | king_fn = ts_function % ('king', replace_colors(king_svg)) 77 | 78 | outfile = parent_dir / 'typescript' / 'pieces.ts' 79 | outfile_text = '\n'.join([ts_start, knight_fn, bishop_fn, rook_fn, queen_fn, king_fn]) 80 | outfile.write_text(outfile_text, encoding='utf-8', newline='\n') 81 | 82 | 83 | if __name__ == '__main__': 84 | load_pieces() 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "outDir": "./out", 6 | "rootDir": "./typescript", 7 | "strict": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUncheckedIndexedAccess": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "newLine": "LF" 19 | }, 20 | "include": [ 21 | "./typescript/**/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /typescript/app.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | import { audioHandle, initializeAudio } from './audio/audio.js' 8 | import { beginSavedState, createMenu, createStyles } from './rendering.js' 9 | 10 | // Disable the context menu 11 | document.addEventListener('contextmenu', event => { 12 | event.preventDefault() 13 | }) 14 | 15 | // https://html.spec.whatwg.org/multipage/interaction.html#activation-triggering-input-event 16 | 17 | document.addEventListener('mousedown', () => { 18 | if (audioHandle.initialized) return 19 | audioHandle.initialize(initializeAudio) 20 | }, { once: true }) 21 | 22 | document.addEventListener('touchend', () => { 23 | if (audioHandle.initialized) return 24 | audioHandle.initialize(initializeAudio) 25 | }, { once: true }) 26 | 27 | createStyles() 28 | createMenu() 29 | 30 | beginSavedState() 31 | -------------------------------------------------------------------------------- /typescript/audio/ImpulseResponse.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | import { getPCM } from '../../node_modules/natlib/audio/audio.js' 8 | import { convertDecibelsToPowerRatio } from '../../node_modules/natlib/audio/decibels.js' 9 | import type { IPrng32 } from '../../node_modules/natlib/prng/prng' 10 | import { randomClosedUnit1Ball } from '../../node_modules/natlib/prng/sampling.js' 11 | 12 | /** Callback function type */ 13 | type AudioCallback = (buf: AudioBuffer) => void 14 | 15 | /** Impulse response class */ 16 | export class ImpulseResponse { 17 | readonly channels: number 18 | readonly sampleRate: number 19 | prng: IPrng32 20 | 21 | constructor(channels: number, sampleRate: number, prng: IPrng32) { 22 | this.channels = channels 23 | this.sampleRate = sampleRate 24 | this.prng = prng 25 | } 26 | 27 | /** Get a reverb impulse response. */ 28 | generateReverb( 29 | done: AudioCallback, 30 | startFrequency: number, 31 | endFrequency: number, 32 | duration: number, 33 | fadeIn = 0, 34 | decayThreshold = -60, 35 | ) { 36 | const length = Math.round(duration * this.sampleRate) 37 | const fadeInLength = Math.round(fadeIn * this.sampleRate) 38 | 39 | const decay = convertDecibelsToPowerRatio(decayThreshold) ** (1 / (length - 1)) 40 | const fade = 1 / (fadeInLength - 1) 41 | 42 | const buf = new AudioBuffer({ 43 | length, 44 | numberOfChannels: this.channels, 45 | sampleRate: this.sampleRate, 46 | }) 47 | 48 | for (const ch of getPCM(buf)) { 49 | for (let n = 0; n < length; ++n) { 50 | ch[n] = randomClosedUnit1Ball(this.prng) * decay ** n 51 | } 52 | for (let n = 0; n < fadeInLength; ++n) { 53 | ch[n]! *= fade * n 54 | } 55 | } 56 | 57 | applyGradualLowpass(done, buf, startFrequency, endFrequency, duration) 58 | } 59 | } 60 | 61 | /** Apply a lowpass filter to the AudioBuffer. */ 62 | export function applyGradualLowpass( 63 | done: AudioCallback, 64 | buf: AudioBuffer, 65 | startFrequency: number, 66 | endFrequency: number, 67 | duration: number, 68 | ) { 69 | const audioContext = new OfflineAudioContext(buf.numberOfChannels, 70 | buf.length, buf.sampleRate) 71 | 72 | const filter = new BiquadFilterNode(audioContext, { 73 | type: 'lowpass', 74 | Q: 0.0001, 75 | frequency: startFrequency, 76 | }) 77 | filter.connect(audioContext.destination) 78 | filter.frequency.exponentialRampToValueAtTime(endFrequency, duration) 79 | 80 | const player = new AudioBufferSourceNode(audioContext, { 81 | buffer: buf, 82 | }) 83 | player.connect(filter) 84 | player.start() 85 | 86 | audioContext.oncomplete = event => { 87 | done(event.renderedBuffer) 88 | } 89 | audioContext.startRendering() 90 | } 91 | -------------------------------------------------------------------------------- /typescript/audio/audio.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | import { convertMidiToFrequency } from '../../node_modules/natlib/audio/audio.js' 8 | import { AudioHandle } from '../../node_modules/natlib/audio/AudioHandle.js' 9 | import type { ExtendedBool } from '../../node_modules/natlib/prelude' 10 | import { Mulberry32 } from '../../node_modules/natlib/prng/Mulberry32.js' 11 | import { randomUint32LessThan } from '../../node_modules/natlib/prng/prng.js' 12 | 13 | import { ImpulseResponse } from './ImpulseResponse.js' 14 | import { play } from './song.js' 15 | 16 | const TEMPO_MUL = 120 / 70 17 | 18 | export const audioHandle = new AudioHandle 19 | 20 | const prng = new Mulberry32(9) 21 | 22 | let audioOut: GainNode 23 | let audioOutEffects: GainNode 24 | let songStart: number 25 | 26 | export const initializeAudio = (con: AudioContext) => { 27 | const mute = localStorage.getItem('king13.mute') === '1' 28 | 29 | audioOut = new GainNode(con, { gain: mute ? 0 : 0.3333 }) 30 | audioOutEffects = new GainNode(con, { gain: 0.3333 }) 31 | 32 | // Reverb 33 | const convolver = new ConvolverNode(con) 34 | const reverbDry = new GainNode(con, { gain: 0.5 }) 35 | const reverbWet = new GainNode(con, { gain: 0.3333 }) 36 | 37 | audioOut.connect(convolver) 38 | audioOut.connect(reverbDry) 39 | audioOutEffects.connect(convolver) 40 | audioOutEffects.connect(reverbDry) 41 | convolver.connect(reverbWet) 42 | reverbDry.connect(con.destination) 43 | reverbWet.connect(con.destination) 44 | 45 | const ir = new ImpulseResponse(2, con.sampleRate, prng) 46 | ir.generateReverb(buf => { 47 | convolver.buffer = buf 48 | 49 | songStart = con.currentTime + 0.05 50 | 51 | enqueue() 52 | setInterval(enqueue, 999) 53 | }, 16000, 1000, 2 * TEMPO_MUL, 0.00001, -90) 54 | } 55 | 56 | export function toggleAudio(off: ExtendedBool) { 57 | if (audioOut) { 58 | audioOut.gain.value = off ? 0 : 0.3333 59 | } 60 | } 61 | 62 | function decay(osc: OscillatorNode, start: number) { 63 | const envelope = new GainNode(audioHandle.con!, { gain: 0.5 }) 64 | envelope.gain.setValueAtTime(0.5, songStart + start) 65 | envelope.gain.exponentialRampToValueAtTime(0.00001, songStart + start + 2 * TEMPO_MUL) 66 | osc.connect(envelope) 67 | return envelope 68 | } 69 | 70 | function playNote(n: number, start: number, end: number) { 71 | start *= TEMPO_MUL 72 | end *= TEMPO_MUL 73 | 74 | const osc = new OscillatorNode(audioHandle.con!, { 75 | type: 'square', 76 | frequency: convertMidiToFrequency(n), 77 | }) 78 | decay(osc, start).connect(audioOut) 79 | osc.start(songStart + start) 80 | osc.stop(songStart + end) 81 | } 82 | 83 | let prevPart = -1 84 | 85 | function enqueue() { 86 | let bufferWanted = audioHandle.con!.currentTime - songStart + 4 87 | let queued = (prevPart + 1) * TEMPO_MUL 88 | 89 | if (queued > bufferWanted) return 90 | bufferWanted += 4 91 | 92 | while (queued < bufferWanted) { 93 | const n = ++prevPart 94 | play((index, start, end) => playNote(index, start + n, end + n), n % 57) 95 | 96 | queued += TEMPO_MUL 97 | } 98 | } 99 | 100 | // Sound effects 101 | 102 | export const enum SoundEffect { 103 | BUTTON_CLICK, 104 | CONNECT, 105 | DISCONNECT, 106 | WIN, 107 | } 108 | 109 | export function sound(effect: SoundEffect) { 110 | if (!audioOutEffects) return 111 | 112 | switch (effect) { 113 | case SoundEffect.BUTTON_CLICK: 114 | playNote2(91, 0, 0.04) // G6 115 | break 116 | 117 | case SoundEffect.CONNECT: 118 | playNote2(76, 0, 0.05) // E5 119 | playNote2(79, 0.05, 0.05) // G5 120 | playNote2(83, 0.1, 0.1) // B5 121 | break 122 | 123 | case SoundEffect.DISCONNECT: 124 | playNote2(83, 0, 0.05) // B5 125 | playNote2(79, 0.05, 0.05) // G5 126 | playNote2(76, 0.1, 0.1) // E5 127 | break 128 | 129 | case SoundEffect.WIN: 130 | playNote2(74, 0, 0.05) // D5 131 | playNote2(76, 0.05, 0.05) // E5 132 | playNote2(79, 0.1, 0.05) // G5 133 | playNote2(83, 0.15, 0.05) // B5 134 | playNote2(86, 0.2, 0.05) // D6 135 | playNote2(88, 0.25, 0.1) // E6 136 | break 137 | 138 | /* 139 | case SoundEffect.LEVEL_END: 140 | playNote2(92, 0, 0.1) // Ab6 141 | playNote2(87, 0.1, 0.1) // Eb6 142 | playNote2(80, 0.2, 0.1) // Ab5 143 | playNote2(82, 0.3, 0.1) // Bb5 144 | break 145 | */ 146 | } 147 | } 148 | 149 | // playNote() but for sound effects 150 | function playNote2(n: number, start: number, duration: number) { 151 | start += audioHandle.con!.currentTime 152 | 153 | const osc = new OscillatorNode(audioHandle.con!, { 154 | type: 'square', 155 | frequency: convertMidiToFrequency(n), 156 | }) 157 | // decay(osc, start).connect(audioOut) 158 | osc.connect(audioOutEffects) 159 | osc.start(start) 160 | osc.stop(start + duration) 161 | } 162 | 163 | // B, C, D, D#, E, F#, G, A 164 | const stepNotes = [35, 36, 38, 39, 40, 42, 43, 45] 165 | 166 | export function step() { 167 | if (!audioOutEffects) return 168 | 169 | const con = audioHandle.con! 170 | 171 | const start = con.currentTime 172 | const duration = 0.2 173 | const frequency = convertMidiToFrequency(stepNotes[randomUint32LessThan(prng, stepNotes.length)]!) 174 | 175 | const osc = new OscillatorNode(con, { 176 | type: 'square', 177 | frequency: frequency, 178 | }) 179 | const gain = new GainNode(con) 180 | 181 | osc.connect(gain) 182 | gain.connect(audioOutEffects) 183 | 184 | osc.frequency.setValueAtTime(frequency, start) 185 | gain.gain.setValueAtTime(1, start) 186 | 187 | osc.frequency.exponentialRampToValueAtTime(0.5 * frequency, start + duration) 188 | gain.gain.exponentialRampToValueAtTime(0.00001, start + duration) 189 | 190 | osc.start(start) 191 | osc.stop(start + duration) 192 | } 193 | -------------------------------------------------------------------------------- /typescript/audio/song.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | /* Magical Power of the Mallet by ZUN 8 | * Transcribed by MTSranger (released under CC BY 4.0) 9 | * Edited by Mark Vasilkov 10 | */ 11 | const MUSIC = [ 12 | [64, 0.0, 0.5, 40, 0.0, 0.25, 67, 0.04, 0.5, 71, 0.08, 0.5, 76, 0.12, 0.5, 47, 0.25, 0.5, 71, 0.5, 1.25, 52, 0.5, 0.75, 55, 0.75, 1.0], 13 | [59, 0.0, 0.25, 76, 0.25, 0.5, 55, 0.25, 0.5, 78, 0.5, 0.75, 52, 0.5, 0.75, 79, 0.75, 1.0, 47, 0.75, 1.0], 14 | [69, 0.0, 0.5, 45, 0.0, 0.25, 72, 0.04, 0.5, 76, 0.08, 0.5, 81, 0.12, 0.5, 52, 0.25, 0.5, 76, 0.5, 1.25, 57, 0.5, 0.75, 60, 0.75, 1.0], 15 | [64, 0.0, 0.25, 81, 0.25, 0.5, 60, 0.25, 0.5, 83, 0.5, 0.75, 57, 0.5, 0.75, 86, 0.75, 1.0, 52, 0.75, 1.0], 16 | [69, 0.0, 0.75, 50, 0.0, 0.25, 74, 0.04, 0.75, 78, 0.08, 0.75, 81, 0.12, 0.75, 57, 0.25, 0.5, 62, 0.5, 0.75, 79, 0.75, 1.0, 66, 0.75, 1.0], 17 | [78, 0.0, 0.25, 69, 0.0, 0.25, 79, 0.25, 0.375, 66, 0.25, 0.5, 78, 0.375, 0.625, 62, 0.5, 0.75, 74, 0.625, 1.0, 57, 0.75, 1.0], 18 | [59, 0.0, 1.0, 35, 0.0, 0.25, 63, 0.04, 1.0, 66, 0.08, 1.0, 71, 0.12, 1.0, 42, 0.25, 0.5, 47, 0.5, 0.75, 51, 0.75, 1.0], 19 | [63, 0.0, 1.0, 54, 0.0, 0.25, 66, 0.04, 1.0, 71, 0.08, 1.0, 75, 0.12, 1.0, 51, 0.25, 0.5, 47, 0.5, 0.75, 42, 0.75, 1.0], 20 | 0, 21 | 1, 22 | 2, 23 | 3, 24 | 4, 25 | 5, 26 | [64, 0.0, 2.0, 40, 0.0, 0.25, 67, 0.04, 2.0, 71, 0.08, 2.0, 76, 0.12, 2.0, 47, 0.25, 0.5, 52, 0.5, 0.75, 55, 0.75, 1.0], 27 | [64, 0.0, 0.25, 59, 0.25, 0.5, 55, 0.5, 0.75, 52, 0.75, 1.0], 28 | [64, 0.0, 0.125, 40, 0.0, 0.25, 71, 0.125, 0.25, 69, 0.25, 0.375, 47, 0.25, 0.5, 71, 0.375, 0.5, 74, 0.5, 0.625, 52, 0.5, 0.75, 71, 0.625, 0.75, 69, 0.75, 0.875, 55, 0.75, 1.0, 71, 0.875, 1.0], 29 | [64, 0.0, 0.125, 59, 0.0, 0.25, 71, 0.125, 0.25, 69, 0.25, 0.375, 55, 0.25, 0.5, 71, 0.375, 0.5, 74, 0.5, 0.625, 52, 0.5, 0.75, 71, 0.625, 0.75, 69, 0.75, 0.875, 47, 0.75, 1.0, 71, 0.875, 1.0], 30 | [66, 0.0, 0.125, 36, 0.0, 0.25, 67, 0.125, 0.25, 66, 0.25, 0.375, 43, 0.25, 0.5, 67, 0.375, 0.5, 71, 0.5, 0.625, 48, 0.5, 0.75, 67, 0.625, 0.75, 66, 0.75, 0.875, 52, 0.75, 1.0, 67, 0.875, 1.0], 31 | [66, 0.0, 0.125, 55, 0.0, 0.25, 67, 0.125, 0.25, 66, 0.25, 0.375, 52, 0.25, 0.5, 67, 0.375, 0.5, 71, 0.5, 0.625, 48, 0.5, 0.75, 67, 0.625, 0.75, 66, 0.75, 0.875, 43, 0.75, 1.0, 67, 0.875, 1.0], 32 | [62, 0.0, 0.125, 38, 0.0, 0.25, 69, 0.125, 0.25, 67, 0.25, 0.375, 45, 0.25, 0.5, 69, 0.375, 0.5, 74, 0.5, 0.625, 50, 0.5, 0.75, 69, 0.625, 0.75, 67, 0.75, 0.875, 54, 0.75, 1.0, 69, 0.875, 1.0], 33 | [62, 0.0, 0.125, 57, 0.0, 0.25, 69, 0.125, 0.25, 67, 0.25, 0.375, 54, 0.25, 0.5, 69, 0.375, 0.5, 74, 0.5, 0.625, 50, 0.5, 0.75, 69, 0.625, 0.75, 67, 0.75, 0.875, 45, 0.75, 1.0, 69, 0.875, 1.0], 34 | [63, 0.0, 0.125, 35, 0.0, 0.25, 71, 0.125, 0.25, 69, 0.25, 0.375, 42, 0.25, 0.5, 71, 0.375, 0.5, 75, 0.5, 0.625, 47, 0.5, 0.75, 71, 0.625, 0.75, 69, 0.75, 0.875, 51, 0.75, 1.0, 71, 0.875, 1.0], 35 | [63, 0.0, 0.125, 54, 0.0, 0.25, 71, 0.125, 0.25, 69, 0.25, 0.375, 51, 0.25, 0.5, 71, 0.375, 0.5, 75, 0.5, 0.625, 47, 0.5, 0.75, 71, 0.625, 0.75, 69, 0.75, 0.875, 42, 0.75, 1.0, 71, 0.875, 1.0], 36 | 16, 37 | 17, 38 | 18, 39 | 19, 40 | 20, 41 | 21, 42 | 22, 43 | [63, 0.0, 0.125, 47, 0.0, 0.25, 71, 0.125, 0.25, 69, 0.25, 0.375, 51, 0.25, 0.5, 71, 0.375, 0.5, 75, 0.5, 0.625, 54, 0.5, 0.75, 71, 0.625, 0.75, 75, 0.75, 0.875, 59, 0.75, 1.0, 78, 0.875, 1.0], 44 | [64, 0.0, 0.5, 40, 0.0, 0.125, 67, 0.04, 0.5, 71, 0.08, 0.5, 76, 0.12, 0.5, 47, 0.125, 0.25, 52, 0.25, 0.375, 55, 0.375, 0.5, 71, 0.5, 1.25, 67, 0.5, 1.25, 59, 0.5, 0.625, 55, 0.625, 0.75, 52, 0.75, 0.875, 47, 0.875, 1.0], 45 | [40, 0.0, 0.125, 52, 0.125, 0.25, 76, 0.25, 0.5, 71, 0.25, 0.5, 55, 0.25, 0.375, 59, 0.375, 0.5, 78, 0.5, 0.75, 71, 0.5, 0.75, 64, 0.5, 0.625, 59, 0.625, 0.75, 79, 0.75, 1.0, 71, 0.75, 1.0, 55, 0.75, 0.875, 52, 0.875, 1.0], 46 | [69, 0.0, 0.5, 45, 0.0, 0.125, 72, 0.04, 0.5, 76, 0.08, 0.5, 81, 0.12, 0.5, 52, 0.125, 0.25, 57, 0.25, 0.375, 60, 0.375, 0.5, 76, 0.5, 1.25, 72, 0.5, 1.25, 64, 0.5, 0.625, 60, 0.625, 0.75, 57, 0.75, 0.875, 52, 0.875, 1.0], 47 | [45, 0.0, 0.125, 57, 0.125, 0.25, 81, 0.25, 0.5, 76, 0.25, 0.5, 60, 0.25, 0.375, 64, 0.375, 0.5, 83, 0.5, 0.75, 76, 0.5, 0.75, 69, 0.5, 0.625, 64, 0.625, 0.75, 86, 0.75, 1.0, 78, 0.75, 1.0, 60, 0.75, 0.875, 57, 0.875, 1.0], 48 | [69, 0.0, 0.75, 50, 0.0, 0.125, 74, 0.04, 0.75, 78, 0.08, 0.75, 81, 0.12, 0.75, 54, 0.125, 0.25, 57, 0.25, 0.375, 62, 0.375, 0.5, 66, 0.5, 0.625, 62, 0.625, 0.75, 79, 0.75, 1.0, 57, 0.75, 0.875, 50, 0.875, 1.0], 49 | [78, 0.0, 0.25, 74, 0.0, 0.25, 38, 0.0, 0.125, 45, 0.125, 0.25, 79, 0.25, 0.375, 50, 0.25, 0.375, 78, 0.375, 0.625, 54, 0.375, 0.5, 57, 0.5, 0.625, 74, 0.625, 1.0, 54, 0.625, 0.75, 50, 0.75, 0.875, 45, 0.875, 1.0], 50 | [59, 0.0, 1.0, 35, 0.0, 0.125, 63, 0.04, 1.0, 66, 0.08, 1.0, 71, 0.12, 1.0, 42, 0.125, 0.25, 47, 0.25, 0.375, 51, 0.375, 0.5, 54, 0.5, 0.625, 51, 0.625, 0.75, 47, 0.75, 0.875, 42, 0.875, 1.0], 51 | [63, 0.0, 1.0, 47, 0.0, 0.125, 66, 0.04, 1.0, 71, 0.08, 1.0, 75, 0.12, 1.0, 51, 0.125, 0.25, 54, 0.25, 0.375, 59, 0.375, 0.5, 63, 0.5, 0.625, 59, 0.625, 0.75, 54, 0.75, 0.875, 51, 0.875, 1.0], 52 | 32, 53 | 33, 54 | 34, 55 | 35, 56 | 36, 57 | 37, 58 | [64, 0.0, 2.0, 40, 0.0, 0.125, 67, 0.04, 2.0, 71, 0.08, 2.0, 76, 0.12, 2.0, 47, 0.125, 0.25, 52, 0.25, 0.375, 55, 0.375, 0.5, 59, 0.5, 0.625, 55, 0.625, 0.75, 52, 0.75, 0.875, 47, 0.875, 1.0], 59 | [40, 0.0, 0.125, 52, 0.125, 0.25, 55, 0.25, 0.375, 59, 0.375, 0.5, 64, 0.5, 0.625, 67, 0.625, 0.75, 71, 0.75, 0.875, 76, 0.875, 1.0], 60 | [79, 0.0, 1.0, 76, 0.0, 1.0, 64, 0.0, 0.125, 67, 0.125, 0.25, 71, 0.25, 0.375, 67, 0.375, 0.5, 71, 0.5, 1.0], 61 | [78, 0.0, 0.5, 74, 0.0, 0.5, 62, 0.0, 0.25, 69, 0.25, 0.5, 81, 0.5, 0.75, 74, 0.5, 1.0, 86, 0.75, 1.0], 62 | [88, 0.0, 2.0, 84, 0.0, 2.0, 67, 0.0, 1.5, 60, 0.0, 1.5], 63 | [60, 0.5, 0.75, 67, 0.75, 1.0], 64 | [91, 0.0, 1.0, 88, 0.0, 1.0, 83, 0.0, 1.0, 79, 0.0, 0.25, 76, 0.25, 0.5, 71, 0.5, 0.75, 67, 0.75, 1.0], 65 | [81, 0.0, 0.3125, 62, 0.0, 1.0, 86, 0.04, 0.3125, 90, 0.08, 0.3125, 86, 0.3125, 0.625, 81, 0.625, 0.9375, 86, 0.9375, 1.25], 66 | [64, 0.25, 2.0, 71, 0.29, 2.0, 76, 0.33, 2.0, 79, 0.37, 2.0, 83, 0.41, 2.0, 88, 0.45, 2.0], 67 | ] 68 | 69 | type PlayNoteFunction = (index: number, start: number, end: number) => void 70 | 71 | export function play(note: PlayNoteFunction, bar: number) { 72 | if (bar > 54) return 73 | const part = ((MUSIC[bar] as any).push ? MUSIC[bar] : MUSIC[(MUSIC[bar] as number)]) as number[] 74 | 75 | for (let n = 0; n < part.length; n += 3) { 76 | note(part[n]!, part[n + 1]!, part[n + 2]!) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /typescript/debug.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | import { PieceSpecies, Settings } from './definitions.js' 8 | import { cellRefs, createPiece } from './rendering.js' 9 | 10 | export const renderBoard = (_: unknown) => { 11 | for (let y = 0; y < Settings.boardHeight; ++y) { 12 | for (let x = 0; x < Settings.boardWidth; ++x) { 13 | const cell = cellRefs[y]![x]! 14 | // Piece value (debug) 15 | const value = x + y * Settings.boardWidth + 1 16 | 17 | if (value === 13) { 18 | cell.append(createPiece(x, y, PieceSpecies.king, Settings.kingValue)) 19 | continue 20 | } 21 | 22 | cell.append(createPiece(x, y, PieceSpecies.knight, value)) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /typescript/definitions.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | import type { IVec2 } from '../node_modules/natlib/Vec2' 8 | import { ShortBool, type ExtendedBool } from '../node_modules/natlib/prelude.js' 9 | import { Mulberry32 } from '../node_modules/natlib/prng/Mulberry32.js' 10 | import { randomUint32LessThan } from '../node_modules/natlib/prng/prng.js' 11 | import { sound, SoundEffect, step } from './audio/audio.js' 12 | 13 | export const enum Settings { 14 | boardWidth = 4, 15 | boardHeight = 4, 16 | kingValue = 10, 17 | outOfBounds = 9, 18 | // Thresholds 19 | bishopThreshold = 4, // '16' 20 | rookThreshold = 6, // '64' 21 | queenThreshold = 8, // '256' 22 | // Save state 23 | stackSize = 4, 24 | } 25 | 26 | export const enum PieceSpecies { 27 | knight = 1, 28 | bishop, 29 | rook, 30 | queen, 31 | king, 32 | } 33 | 34 | type ReadonlyVec2 = Readonly 35 | type Optional = T | null | undefined 36 | type Piece = { species: PieceSpecies, value: number } 37 | type BoardRow = [Optional, Optional, Optional, Optional] 38 | export type Board = [BoardRow, BoardRow, BoardRow, BoardRow] 39 | 40 | const createBoard = (): Board => { 41 | return [ 42 | [, , , ,], 43 | [, , , ,], 44 | [, , , ,], 45 | [, , , ,], 46 | ] 47 | } 48 | 49 | const copyPiece = (piece: Optional): Optional => { 50 | if (!piece) return 51 | const { species, value } = piece 52 | return { species, value } 53 | } 54 | 55 | const copyBoard = (board: Board): Board => { 56 | return [ 57 | Array.from(board[0], copyPiece) as BoardRow, 58 | Array.from(board[1], copyPiece) as BoardRow, 59 | Array.from(board[2], copyPiece) as BoardRow, 60 | Array.from(board[3], copyPiece) as BoardRow, 61 | ] 62 | } 63 | 64 | export let board: Board 65 | /** Selected cell */ 66 | export let selected: Optional 67 | /** Cell vacated in the last turn */ 68 | export let vacated: Optional 69 | /** Cell occupied in the last turn */ 70 | export let occupied: Optional 71 | /** Position of the last spawned piece */ 72 | export let spawned: Optional 73 | /** Cell vacated by king */ 74 | export let kingVacated: Optional 75 | /** Cell occupied by king */ 76 | export let kingOccupied: Optional 77 | /** Highest value achieved */ 78 | export let highestValue: number 79 | /** Highest species spawned */ 80 | export let highestSpecies: PieceSpecies 81 | export let score: number 82 | export let ended: ExtendedBool 83 | export let kingAttack: ExtendedBool 84 | let prng: Mulberry32 85 | 86 | export const reset = (seed?: number) => { 87 | board = createBoard() 88 | selected = null 89 | vacated = null 90 | occupied = null 91 | spawned = null 92 | kingVacated = null 93 | kingOccupied = null 94 | highestValue = 1 95 | highestSpecies = PieceSpecies.knight 96 | score = 0 97 | ended = ShortBool.FALSE 98 | kingAttack = ShortBool.FALSE 99 | prng = new Mulberry32(seed ?? Date.now()) 100 | } 101 | 102 | // reset() 103 | 104 | type IState = [ 105 | board: Board, 106 | xVacated: number, 107 | yVacated: number, 108 | xOccupied: number, 109 | yOccupied: number, 110 | xSpawned: number, 111 | ySpawned: number, 112 | xKingVacated: number, 113 | yKingVacated: number, 114 | xKingOccupied: number, 115 | yKingOccupied: number, 116 | highestValue: number, 117 | highestSpecies: PieceSpecies, 118 | score: number, 119 | seed: number, 120 | ] 121 | 122 | const takeState = (): IState => { 123 | return [ 124 | copyBoard(board), 125 | vacated?.x ?? Settings.outOfBounds, 126 | vacated?.y ?? Settings.outOfBounds, 127 | occupied?.x ?? Settings.outOfBounds, 128 | occupied?.y ?? Settings.outOfBounds, 129 | spawned?.x ?? Settings.outOfBounds, 130 | spawned?.y ?? Settings.outOfBounds, 131 | kingVacated?.x ?? Settings.outOfBounds, 132 | kingVacated?.y ?? Settings.outOfBounds, 133 | kingOccupied?.x ?? Settings.outOfBounds, 134 | kingOccupied?.y ?? Settings.outOfBounds, 135 | highestValue, 136 | highestSpecies, 137 | score, 138 | prng.state, 139 | ] 140 | } 141 | 142 | const restoreState = (state: IState) => { 143 | const [ 144 | _board, 145 | xVacated, 146 | yVacated, 147 | xOccupied, 148 | yOccupied, 149 | xSpawned, 150 | ySpawned, 151 | xKingVacated, 152 | yKingVacated, 153 | xKingOccupied, 154 | yKingOccupied, 155 | _highestValue, 156 | _highestSpecies, 157 | _score, 158 | seed, 159 | ] = state 160 | 161 | reset(seed) 162 | 163 | board = copyBoard(_board) 164 | 165 | vacated = xVacated === Settings.outOfBounds ? null : { x: xVacated, y: yVacated } 166 | occupied = xOccupied === Settings.outOfBounds ? null : { x: xOccupied, y: yOccupied } 167 | spawned = xSpawned === Settings.outOfBounds ? null : { x: xSpawned, y: ySpawned } 168 | kingVacated = xKingVacated === Settings.outOfBounds ? null : { x: xKingVacated, y: yKingVacated } 169 | kingOccupied = xKingOccupied === Settings.outOfBounds ? null : { x: xKingOccupied, y: yKingOccupied } 170 | 171 | highestValue = _highestValue 172 | highestSpecies = _highestSpecies 173 | score = _score 174 | } 175 | 176 | // #region Stack 177 | 178 | export let stack: IState[] = [] 179 | 180 | export const replaceStack = (_stack: IState[]) => { 181 | stack = _stack 182 | } 183 | 184 | export const pushInitialState = () => { 185 | stack = [takeState()] 186 | } 187 | 188 | export const pushState = () => { 189 | stack.push(takeState()) 190 | if (stack.length > Settings.stackSize) stack.shift() 191 | } 192 | 193 | export const restoreLastState = () => { 194 | const lastState = stack.at(-1) 195 | if (lastState) restoreState(lastState) 196 | } 197 | 198 | export const popState = (): ExtendedBool => { 199 | if (stack.length < 2) return 200 | stack.pop() 201 | restoreLastState() 202 | return ShortBool.TRUE 203 | } 204 | 205 | // #endregion 206 | 207 | const getRandomElement = (array: T[]): Optional => { 208 | switch (array.length) { 209 | case 0: return 210 | case 1: return array[0] 211 | } 212 | return array[randomUint32LessThan(prng, array.length)] 213 | } 214 | 215 | export const spawn = () => { 216 | const vacant: ReadonlyVec2[] = [] 217 | 218 | for (let y = 0; y < Settings.boardHeight; ++y) { 219 | for (let x = 0; x < Settings.boardWidth; ++x) { 220 | if (!board[y]![x]) { 221 | // Move is still in progress, don't spawn there 222 | if (kingVacated && kingVacated.x === x && kingVacated.y === y) continue 223 | 224 | vacant.push({ x, y }) 225 | } 226 | } 227 | } 228 | 229 | if (!vacant.length) { 230 | ended = ShortBool.TRUE 231 | return 232 | } 233 | 234 | let species = PieceSpecies.knight 235 | switch (randomUint32LessThan(prng, 9)) { 236 | case 0: 237 | case 1: 238 | case 2: 239 | // 33.3% chance 240 | if (highestValue >= Settings.bishopThreshold) species = PieceSpecies.bishop 241 | break 242 | 243 | case 3: 244 | case 4: 245 | // 22.2% chance 246 | if (highestValue >= Settings.rookThreshold) species = PieceSpecies.rook 247 | break 248 | 249 | case 5: 250 | // 11.1% chance 251 | if (highestValue >= Settings.queenThreshold) species = PieceSpecies.queen 252 | } 253 | 254 | // Guaranteed pieces 255 | if (highestValue >= Settings.queenThreshold && highestSpecies < PieceSpecies.queen) species = PieceSpecies.queen 256 | else if (highestValue >= Settings.rookThreshold && highestSpecies < PieceSpecies.rook) species = PieceSpecies.rook 257 | else if (highestValue >= Settings.bishopThreshold && highestSpecies < PieceSpecies.bishop) species = PieceSpecies.bishop 258 | 259 | if (species > highestSpecies) highestSpecies = species 260 | 261 | let value = 1 262 | switch (randomUint32LessThan(prng, 9)) { 263 | case 0: 264 | case 1: 265 | // 22.2% chance 266 | if (highestValue >= Settings.bishopThreshold) value = 2 267 | break 268 | 269 | case 2: 270 | // 11.1% chance 271 | if (highestValue >= Settings.rookThreshold) value = 3 272 | } 273 | 274 | const { x, y } = getRandomElement(vacant)! 275 | 276 | if (vacated && vacated.x === x && vacated.y === y) { 277 | const { x, y } = getRandomElement(vacant)! 278 | board[y]![x] = { species, value } 279 | spawned = { x, y } 280 | return 281 | } 282 | 283 | board[y]![x] = { species, value } 284 | spawned = { x, y } 285 | } 286 | 287 | export const setSpawned = (x: number, y: number) => { 288 | spawned = { x, y } 289 | } 290 | 291 | export const getMoves = (x0: number, y0: number): ReadonlyVec2[] => { 292 | const moves: ReadonlyVec2[] = [] 293 | 294 | const putMove = (Δx: number, Δy: number): ExtendedBool => { 295 | const x = x0 + Δx 296 | const y = y0 + Δy 297 | 298 | let passable: boolean 299 | 300 | if (x >= 0 && x < Settings.boardWidth && 301 | y >= 0 && y < Settings.boardHeight && 302 | ((passable = !board[y]![x]) || board[y]![x].value === board[y0]![x0]?.value)) { 303 | 304 | moves.push({ x, y }) 305 | 306 | return passable 307 | } 308 | 309 | return 310 | } 311 | 312 | const piece = board[y0]![x0] 313 | if (!piece) return moves 314 | 315 | const { species } = piece 316 | 317 | switch (species) { 318 | case PieceSpecies.knight: 319 | putMove(-2, -1) 320 | putMove(-2, 1) 321 | putMove(-1, -2) 322 | putMove(-1, 2) 323 | putMove(1, -2) 324 | putMove(1, 2) 325 | putMove(2, -1) 326 | putMove(2, 1) 327 | break 328 | 329 | case PieceSpecies.bishop: 330 | putMove(-1, -1) && putMove(-2, -2) && putMove(-3, -3) 331 | putMove(-1, 1) && putMove(-2, 2) && putMove(-3, 3) 332 | putMove(1, -1) && putMove(2, -2) && putMove(3, -3) 333 | putMove(1, 1) && putMove(2, 2) && putMove(3, 3) 334 | break 335 | 336 | case PieceSpecies.rook: 337 | putMove(-1, 0) && putMove(-2, 0) && putMove(-3, 0) 338 | putMove(0, -1) && putMove(0, -2) && putMove(0, -3) 339 | putMove(1, 0) && putMove(2, 0) && putMove(3, 0) 340 | putMove(0, 1) && putMove(0, 2) && putMove(0, 3) 341 | break 342 | 343 | case PieceSpecies.queen: 344 | putMove(-1, -1) && putMove(-2, -2) && putMove(-3, -3) 345 | putMove(-1, 1) && putMove(-2, 2) && putMove(-3, 3) 346 | putMove(1, -1) && putMove(2, -2) && putMove(3, -3) 347 | putMove(1, 1) && putMove(2, 2) && putMove(3, 3) 348 | 349 | putMove(-1, 0) && putMove(-2, 0) && putMove(-3, 0) 350 | putMove(0, -1) && putMove(0, -2) && putMove(0, -3) 351 | putMove(1, 0) && putMove(2, 0) && putMove(3, 0) 352 | putMove(0, 1) && putMove(0, 2) && putMove(0, 3) 353 | } 354 | 355 | return moves 356 | } 357 | 358 | export const getMovesTable = (x0: number, y0: number): Board => { 359 | const moves = createBoard() 360 | 361 | getMoves(x0, y0).forEach(({ x, y }) => { 362 | moves[y]![x] = ShortBool.TRUE 363 | }) 364 | 365 | return moves 366 | } 367 | 368 | export const getPositionsWithMoves = (): ReadonlyVec2[] => { 369 | const positions: ReadonlyVec2[] = [] 370 | 371 | for (let y = 0; y < Settings.boardHeight; ++y) { 372 | for (let x = 0; x < Settings.boardWidth; ++x) { 373 | const piece = board[y]![x] 374 | 375 | if (piece && piece.species !== PieceSpecies.king && getMoves(x, y).length) { 376 | positions.push({ x, y }) 377 | } 378 | } 379 | } 380 | 381 | if (!positions.length) { 382 | ended = ShortBool.TRUE 383 | } 384 | 385 | return positions 386 | } 387 | 388 | export const interact = (x: number, y: number): ExtendedBool => { 389 | let changedBoard: ExtendedBool 390 | 391 | // Select 392 | if (!selected) { 393 | if (board[y]![x]) selected = { x, y } 394 | } 395 | 396 | // Deselect 397 | else if (selected.x === x && selected.y === y) { 398 | selected = null 399 | } 400 | 401 | // Move 402 | else if (!board[y]![x]) { 403 | const moves = getMovesTable(selected.x, selected.y) 404 | 405 | if (moves[y]![x]) { 406 | board[y]![x] = board[selected.y]![selected.x] 407 | board[selected.y]![selected.x] = null 408 | vacated = selected 409 | occupied = { x, y } 410 | selected = null 411 | 412 | playKing() 413 | spawn() 414 | 415 | changedBoard = ShortBool.TRUE 416 | 417 | if (kingAttack) { 418 | sound(SoundEffect.DISCONNECT) 419 | } 420 | else { 421 | step() 422 | } 423 | } 424 | else { 425 | // Move isn't possible, deselect instead 426 | selected = null 427 | } 428 | } 429 | 430 | // Merge 431 | else if (board[y]![x].value === board[selected.y]![selected.x]?.value) { 432 | const moves = getMovesTable(selected.x, selected.y) 433 | 434 | if (moves[y]![x]) { 435 | board[y]![x] = board[selected.y]![selected.x]! // Copy species 436 | score += 2 ** board[y]![x].value 437 | const value = ++board[y]![x].value 438 | board[selected.y]![selected.x] = null 439 | vacated = selected 440 | occupied = { x, y } 441 | selected = null 442 | 443 | if (value > highestValue) highestValue = value 444 | 445 | // Regicide ending 446 | if (value > Settings.kingValue) ended = ShortBool.TRUE 447 | else { 448 | const nextMove = getMoves(x, y).some(move => board[move.y]![move.x]?.value === value) 449 | if (nextMove) { 450 | // The piece can continue the chain. Select it 451 | // and don't spawn new pieces. 452 | selected = { x, y } 453 | } 454 | else { 455 | playKing() 456 | spawn() 457 | } 458 | } 459 | 460 | changedBoard = ShortBool.TRUE 461 | 462 | if (!ended) { 463 | if (kingAttack) { 464 | sound(SoundEffect.DISCONNECT) 465 | } 466 | else { 467 | sound(SoundEffect.CONNECT) 468 | } 469 | } 470 | } 471 | else { 472 | // Merge isn't possible, select instead 473 | selected = { x, y } 474 | } 475 | } 476 | 477 | // Select 478 | else if (board[y]![x]) { 479 | selected = { x, y } 480 | } 481 | 482 | return changedBoard 483 | } 484 | 485 | const pieceWorth = (piece: Piece): number => 486 | piece.species + piece.value 487 | 488 | export const playKing = () => { 489 | let x0: number = Settings.outOfBounds 490 | let y0: number = Settings.outOfBounds 491 | let availableCells = 0 492 | 493 | for (let y = 0; y < Settings.boardHeight; ++y) { 494 | for (let x = 0; x < Settings.boardWidth; ++x) { 495 | const piece = board[y]![x] 496 | if (!piece) { 497 | ++availableCells 498 | } 499 | else if (piece.species === PieceSpecies.king) { 500 | x0 = x 501 | y0 = y 502 | } 503 | } 504 | } 505 | 506 | // King not found, OR the board is full, OR it will be full after the next spawn. 507 | if (x0 === Settings.outOfBounds || availableCells === 0 || availableCells === 1) { 508 | kingVacated = null 509 | kingOccupied = null 510 | return 511 | } 512 | 513 | const possibleMoves: IVec2[] = [] 514 | let possibleTakes: (IVec2 & { worth: number })[] = [] 515 | 516 | const putMove = (Δx: number, Δy: number) => { 517 | const x = x0 + Δx 518 | const y = y0 + Δy 519 | 520 | if (x >= 0 && x < Settings.boardWidth && 521 | y >= 0 && y < Settings.boardHeight) { 522 | 523 | const piece = board[y]![x] 524 | if (!piece) { 525 | possibleMoves.push({ x, y }) 526 | } 527 | else { 528 | // Move is still in progress, don't take the piece 529 | if (occupied && occupied.x === x && occupied.y === y) return 530 | 531 | possibleTakes.push({ x, y, worth: pieceWorth(piece) }) 532 | } 533 | } 534 | } 535 | 536 | putMove(-1, -1) 537 | putMove(-1, 0) 538 | putMove(-1, 1) 539 | putMove(0, -1) 540 | putMove(0, 1) 541 | putMove(1, -1) 542 | putMove(1, 0) 543 | putMove(1, 1) 544 | 545 | // Sort by worth, descending 546 | possibleTakes.sort((a, b) => b.worth - a.worth) 547 | 548 | const highestWorth = possibleTakes[0]?.worth ?? 0 549 | 550 | possibleTakes = possibleTakes.filter(({ worth }) => worth === highestWorth) 551 | 552 | // Take only when the target piece is present, AND the king is surrounded, AND the board isn't full. 553 | if (highestWorth && !possibleMoves.length /* && !boardFull */) { 554 | const { x, y } = getRandomElement(possibleTakes)! 555 | 556 | if (selected && selected.x === x && selected.y === y) selected = null 557 | 558 | score -= 2 ** board[y]![x]!.value 559 | board[y]![x] = board[y0]![x0] 560 | board[y0]![x0] = null 561 | kingVacated = { x: x0, y: y0 } 562 | kingOccupied = { x, y } 563 | kingAttack = ShortBool.TRUE 564 | } 565 | else if (possibleMoves.length) { 566 | const { x, y } = getRandomElement(possibleMoves)! 567 | 568 | board[y]![x] = board[y0]![x0] 569 | board[y0]![x0] = null 570 | kingVacated = { x: x0, y: y0 } 571 | kingOccupied = { x, y } 572 | kingAttack = ShortBool.FALSE 573 | } 574 | } 575 | 576 | export const getScore = (): number => { 577 | let _score = score 578 | 579 | for (let y = 0; y < Settings.boardHeight; ++y) { 580 | for (let x = 0; x < Settings.boardWidth; ++x) { 581 | const piece = board[y]![x] 582 | 583 | if (!piece || piece.species === PieceSpecies.king || piece.value > Settings.kingValue) continue 584 | 585 | _score -= 2 ** (piece.value - 1) 586 | } 587 | } 588 | 589 | return _score 590 | } 591 | -------------------------------------------------------------------------------- /typescript/icons.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | export const menuSVG = 8 | `` 9 | 10 | export const musicSVG = 11 | `` 12 | 13 | export const undoSVG = 14 | `` 15 | -------------------------------------------------------------------------------- /typescript/pieces.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | export const knightSVG = (color: string, outline: string, highlight: string, lowlight: string, _lowlight2: string) => 8 | `` 9 | 10 | export const bishopSVG = (color: string, outline: string, highlight: string, lowlight: string, _lowlight2: string) => 11 | `` 12 | 13 | export const rookSVG = (color: string, outline: string, highlight: string, lowlight: string, _lowlight2: string) => 14 | `` 15 | 16 | export const queenSVG = (color: string, outline: string, highlight: string, lowlight: string, _lowlight2: string) => 17 | `` 18 | 19 | export const kingSVG = (color: string, outline: string, highlight: string, lowlight: string, _lowlight2: string) => 20 | `` 21 | -------------------------------------------------------------------------------- /typescript/rendering.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | import { ShortBool, type ExtendedBool } from '../node_modules/natlib/prelude.js' 8 | import { sound, SoundEffect, step, toggleAudio } from './audio/audio.js' 9 | 10 | import { board, ended, getMovesTable, getPositionsWithMoves, getScore, highestValue, interact, kingAttack, kingOccupied, kingVacated, occupied, PieceSpecies, popState, pushInitialState, pushState, replaceStack, reset, restoreLastState, selected, setSpawned, Settings, spawn, spawned, stack, vacated, type Board } from './definitions.js' 11 | import { menuSVG, musicSVG, undoSVG } from './icons.js' 12 | import { bishopSVG, kingSVG, knightSVG, queenSVG, rookSVG } from './pieces.js' 13 | import { shareTwitter } from './share.js' 14 | 15 | const pieceColors = [ 16 | '#1a1c2c', 17 | '#34223f', 18 | '#4c2550', 19 | '#63285d', 20 | '#782d5d', 21 | '#8c325c', 22 | '#9e3859', 23 | '#b03e54', 24 | '#bd4854', 25 | '#c95154', 26 | '#d55c53', 27 | '#df6755', 28 | '#e87356', 29 | '#ef7e57', 30 | '#f48c5c', 31 | '#f79a60', 32 | '#faa765', 33 | '#fcb36a', 34 | '#fec070', 35 | '#ffcd75', 36 | ] 37 | 38 | const kingColors = [ 39 | '#17001d', 40 | '#300123', 41 | '#450428', 42 | '#58092d', 43 | '#690f31', 44 | '#7b1235', 45 | '#8d1539', 46 | '#9e173b', 47 | '#af173d', 48 | '#bf1640', 49 | '#d01441', 50 | '#df1143', 51 | '#ef0c45', 52 | // '#ff0546', 53 | ] 54 | 55 | type PieceColors = [color: string, outline: string, highlight: string, lowlight: string, lowlight2: string] 56 | 57 | export const getColors = (value: number): PieceColors => { 58 | let _pieceColors = pieceColors 59 | if (value === Settings.kingValue) { 60 | _pieceColors = kingColors 61 | value = 1 62 | } 63 | 64 | const color = _pieceColors.at(-value - 3)! 65 | const outline = _pieceColors.at(0)! 66 | const highlight = _pieceColors.at(-value)! 67 | const lowlight = _pieceColors.at(-value - 6)! 68 | const lowlight2 = _pieceColors.at(-value - 8)! 69 | 70 | return [color, outline, highlight, lowlight, lowlight2] 71 | } 72 | 73 | const boardRef = document.querySelector('.b')! as HTMLElement 74 | // let suggestMoves: ReturnType = [] 75 | 76 | const bindClick = (cell: Element, x: number, y: number) => { 77 | cell.addEventListener('click', () => { 78 | const changedBoard = interact(x, y) 79 | getPositionsWithMoves() 80 | 81 | if (ended) ending() 82 | else if (changedBoard) { 83 | pushState() 84 | 85 | localStorage.setItem('king13.stack', JSON.stringify(stack)) 86 | } 87 | 88 | boardRef.classList.remove('sh') 89 | 90 | renderBoard() 91 | }) 92 | } 93 | 94 | const getCellRefs = () => { 95 | const cellRefs: Element[][] = [] 96 | const cells = document.querySelectorAll('.c') 97 | 98 | for (let y = 0; y < Settings.boardHeight; ++y) { 99 | cellRefs[y] = [] 100 | 101 | for (let x = 0; x < Settings.boardWidth; ++x) { 102 | bindClick(cellRefs[y]![x] = cells[4 * y + x]!, x, y) 103 | } 104 | } 105 | 106 | return cellRefs 107 | } 108 | 109 | export const cellRefs = getCellRefs() 110 | 111 | type SpeciesSVG = typeof knightSVG 112 | 113 | const speciesSVG: Record = { 114 | [PieceSpecies.knight]: knightSVG, 115 | [PieceSpecies.bishop]: bishopSVG, 116 | [PieceSpecies.rook]: rookSVG, 117 | [PieceSpecies.queen]: queenSVG, 118 | [PieceSpecies.king]: kingSVG, 119 | } 120 | 121 | let patternIndex = 0 122 | 123 | const getPatternSVG = (background: string, color: string) => 124 | `` 125 | 126 | let vacatedLast: typeof vacated 127 | let spawnedLast: typeof spawned 128 | let kingVacatedLast: typeof kingVacated 129 | 130 | export const createPiece = (x: number, y: number, species: PieceSpecies, value: number) => { 131 | const piece = document.createElement('div') 132 | 133 | piece.className = `p ps${species}` 134 | 135 | const colorIndex = (value - 1) % Settings.kingValue + 1 136 | const colors = getColors(colorIndex) 137 | 138 | let svg: string 139 | 140 | if (value === Settings.kingValue || value % 2) { 141 | svg = speciesSVG[species](...colors) 142 | } 143 | else { 144 | const colors = getColors(colorIndex + 1) 145 | const lightColors = getColors(colorIndex - 1) 146 | 147 | const colorPattern = getPatternSVG(colors[0], lightColors[0]) 148 | colors[0] = `url(#pa${patternIndex})` 149 | 150 | const highlightPattern = getPatternSVG(colors[2], lightColors[2]) 151 | colors[2] = `url(#pa${patternIndex})` 152 | 153 | const lowlightPattern = getPatternSVG(colors[3], lightColors[3]) 154 | colors[3] = `url(#pa${patternIndex})` 155 | 156 | svg = speciesSVG[species](...colors) 157 | .replace('', '' + colorPattern + highlightPattern + lowlightPattern + '') 158 | } 159 | 160 | // Change to setHTMLUnsafe() in 2025 161 | piece.innerHTML = svg 162 | 163 | if (vacated && vacated !== vacatedLast && occupied?.x === x && occupied?.y === y) { 164 | // easeOutQuad 165 | piece.style.animation = `.2s cubic-bezier(.5,1,.89,1) t${vacated.x}${vacated.y}${x}${y}` 166 | vacatedLast = vacated 167 | } 168 | 169 | else if (spawned && spawned !== spawnedLast && spawned?.x === x && spawned?.y === y) { 170 | // easeOutQuad 171 | piece.style.animation = `.2s cubic-bezier(.5,1,.89,1) sp` 172 | spawnedLast = spawned 173 | } 174 | 175 | if (kingVacated && kingVacated !== kingVacatedLast && kingOccupied?.x === x && kingOccupied?.y === y) { 176 | // easeOutQuad 177 | piece.style.animation = `.2s cubic-bezier(.5,1,.89,1) t${kingVacated.x}${kingVacated.y}${x}${y}` 178 | kingVacatedLast = kingVacated 179 | 180 | if (kingAttack) { 181 | boardRef.classList.add('sh') 182 | } 183 | } 184 | 185 | // Outline 186 | const g = piece.firstChild!.firstChild! 187 | const path = g.firstChild! 188 | const copy = path.cloneNode() as SVGPathElement 189 | 190 | const thiccness = +copy.getAttribute('stroke-width')! 191 | copy.setAttribute('stroke-width', '' + (4 * thiccness)) 192 | copy.setAttribute('stroke-linejoin', 'round') 193 | 194 | // copy.setAttribute('class', 'st') 195 | copy.classList.add('st') 196 | g.insertBefore(copy, path) 197 | 198 | // Value 199 | const val = document.createElement('div') 200 | 201 | // val.className = `n n${value}` 202 | val.className = 'n' 203 | if (species === PieceSpecies.king) { 204 | val.textContent = 'XIII' 205 | val.style.color = colors[4] 206 | // val.style.textShadow = `.1vmin .1vmin ${colors[2]}` 207 | } 208 | else { 209 | val.textContent = '' + 2 ** value 210 | val.style.backgroundColor = colors[1] + '90' 211 | val.style.color = colors[2] 212 | } 213 | 214 | piece.append(val) 215 | 216 | return piece 217 | } 218 | 219 | export const renderBoard = (spawnMany?: ExtendedBool) => { 220 | let highlightMoves: Board | undefined 221 | 222 | if (selected && board[selected.y]![selected.x]) { 223 | highlightMoves = getMovesTable(selected.x, selected.y) 224 | } 225 | 226 | for (let y = 0; y < Settings.boardHeight; ++y) { 227 | for (let x = 0; x < Settings.boardWidth; ++x) { 228 | const cell = cellRefs[y]![x]! 229 | 230 | if (selected?.x === x && selected?.y === y) { 231 | cell.classList.add('s') 232 | } 233 | else { 234 | cell.classList.remove('s') 235 | } 236 | 237 | if (highlightMoves?.[y]![x]) { 238 | cell.classList.add('a') 239 | } 240 | else { 241 | cell.classList.remove('a') 242 | } 243 | 244 | const piece = cell.firstChild 245 | 246 | let species = PieceSpecies.knight 247 | let value = 0 248 | 249 | const boardPiece = board[y]![x] 250 | if (boardPiece) { 251 | species = boardPiece.species 252 | value = boardPiece.value 253 | } 254 | 255 | if (piece && !value) { 256 | cell.removeChild(piece) 257 | } 258 | 259 | else if (!piece && value) { 260 | if (spawnMany) setSpawned(x, y) 261 | cell.append(createPiece(x, y, species, value)) 262 | } 263 | 264 | else if (piece && value) { 265 | piece.replaceWith(createPiece(x, y, species, value)) 266 | } 267 | } 268 | } 269 | } 270 | 271 | export const createStyles = () => { 272 | const cellSize = 22.275 273 | let css: string[] = [] 274 | 275 | for (let y0 = 0; y0 < Settings.boardHeight; ++y0) { 276 | for (let x0 = 0; x0 < Settings.boardWidth; ++x0) { 277 | for (let y1 = 0; y1 < Settings.boardHeight; ++y1) { 278 | for (let x1 = 0; x1 < Settings.boardWidth; ++x1) { 279 | if (x0 === x1 && y0 === y1) continue 280 | 281 | const Δx = cellSize * (x0 - x1) 282 | const Δy = cellSize * (y0 - y1) 283 | 284 | css.push(`@keyframes t${x0}${y0}${x1}${y1}{0%{transform:translate(${Δx}vmin,${Δy}vmin)}100%{transform:translate(0,0)}}`) 285 | } 286 | } 287 | } 288 | } 289 | 290 | const style = document.createElement('style') 291 | style.textContent = css.join('') 292 | document.head.append(style) 293 | 294 | document.addEventListener('animationstart', event => { 295 | (event.target as Element | null)?.classList.add('an') 296 | }) 297 | 298 | document.addEventListener('animationend', event => { 299 | (event.target as Element | null)?.classList.remove('an') 300 | }) 301 | } 302 | 303 | export const begin = () => { 304 | reset() 305 | 306 | // Success 307 | // board[2][0] = { species: PieceSpecies.queen, value: 10 } 308 | 309 | // Defeat 310 | // board[0][0] = { species: PieceSpecies.knight, value: 2 } 311 | // board[0][1] = { species: PieceSpecies.knight, value: 2 } 312 | // board[0][2] = { species: PieceSpecies.knight, value: 2 } 313 | // board[0][3] = { species: PieceSpecies.knight, value: 2 } 314 | // board[1][0] = { species: PieceSpecies.bishop, value: 3 } 315 | // board[1][1] = { species: PieceSpecies.bishop, value: 3 } 316 | // board[1][2] = { species: PieceSpecies.bishop, value: 3 } 317 | // board[1][3] = { species: PieceSpecies.bishop, value: 3 } 318 | // board[2][0] = { species: PieceSpecies.knight, value: 4 } 319 | // board[2][1] = { species: PieceSpecies.knight, value: 4 } 320 | // board[2][2] = { species: PieceSpecies.knight, value: 4 } 321 | // board[2][3] = { species: PieceSpecies.knight, value: 4 } 322 | 323 | // Screen shake 324 | // board[0][0] = { species: PieceSpecies.rook, value: 1 } 325 | // board[1][0] = { species: PieceSpecies.knight, value: 1 } 326 | // board[1][1] = { species: PieceSpecies.knight, value: 1 } 327 | // board[1][2] = { species: PieceSpecies.knight, value: 1 } 328 | // board[1][3] = { species: PieceSpecies.knight, value: 1 } 329 | // board[2][0] = { species: PieceSpecies.knight, value: 1 } 330 | // board[2][1] = { species: PieceSpecies.knight, value: 1 } 331 | // board[2][2] = { species: PieceSpecies.knight, value: 1 } 332 | // board[2][3] = { species: PieceSpecies.knight, value: 1 } 333 | // board[3][1] = { species: PieceSpecies.knight, value: 1 } 334 | // board[3][2] = { species: PieceSpecies.knight, value: 1 } 335 | // board[3][3] = { species: PieceSpecies.knight, value: 1 } 336 | 337 | // King 338 | board[3][0] = { species: PieceSpecies.king, value: Settings.kingValue } 339 | 340 | spawn() 341 | spawn() 342 | 343 | pushInitialState() 344 | 345 | localStorage.removeItem('king13.stack') 346 | 347 | renderBoard(ShortBool.TRUE) 348 | } 349 | 350 | export const beginSavedState = () => { 351 | reset() 352 | 353 | let loaded: ExtendedBool 354 | 355 | const serialStack = localStorage.getItem('king13.stack') 356 | if (serialStack) { 357 | try { 358 | const stack = JSON.parse(serialStack) 359 | if (Array.isArray(stack)) { 360 | replaceStack(stack) 361 | loaded = ShortBool.TRUE 362 | } 363 | } 364 | catch (_) { 365 | } 366 | } 367 | 368 | if (!loaded) { 369 | begin() 370 | return 371 | } 372 | 373 | restoreLastState() 374 | 375 | vacatedLast = vacated 376 | // spawnedLast = spawned 377 | kingVacatedLast = kingVacated 378 | 379 | renderBoard(ShortBool.TRUE) 380 | } 381 | 382 | let audioOn = true 383 | 384 | export const createMenu = () => { 385 | const sideButtons = document.querySelectorAll('.tb') 386 | const sideMenuButton = sideButtons[0]! 387 | const sideMusicButton = sideButtons[1]! 388 | const sideUndoButton = sideButtons[2]! 389 | 390 | sideMenuButton.innerHTML = menuSVG 391 | sideMusicButton.innerHTML = musicSVG 392 | sideUndoButton.innerHTML = undoSVG 393 | 394 | const menus = document.querySelectorAll('.u') 395 | const defaultMenu = menus[0]! 396 | const endingMenu = menus[1]! 397 | 398 | const defaultMenuButtons = defaultMenu.querySelectorAll('.bu') 399 | const defaultContinueButton = defaultMenuButtons[0]! 400 | const defaultNewGameButton = defaultMenuButtons[1]! 401 | const defaultMusicButton = defaultMenuButtons[2]! 402 | 403 | const endingMenuButtons = endingMenu.querySelectorAll('.bu') 404 | const endingShareButton = endingMenuButtons[0]! 405 | const endingNewGameButton = endingMenuButtons[1]! 406 | 407 | // Saved audio state 408 | const mute = localStorage.getItem('king13.mute') === '1' 409 | if (mute) { 410 | audioOn = false 411 | sideMusicButton.classList.add('of') 412 | defaultMusicButton.textContent = 'MUSIC: OFF' 413 | } 414 | 415 | // Menu 416 | 417 | sideMenuButton.addEventListener('click', () => { 418 | if (ended) return 419 | 420 | defaultMenu.classList.toggle('h') 421 | 422 | step() 423 | }) 424 | 425 | defaultContinueButton.addEventListener('click', () => { 426 | defaultMenu.classList.add('h') 427 | 428 | step() 429 | }) 430 | 431 | // New Game 432 | 433 | defaultNewGameButton.addEventListener('click', () => { 434 | defaultMenu.classList.add('h') 435 | 436 | begin() 437 | 438 | sound(SoundEffect.BUTTON_CLICK) 439 | }) 440 | 441 | endingNewGameButton.addEventListener('click', () => { 442 | endingMenu.classList.add('h') 443 | 444 | begin() 445 | 446 | sound(SoundEffect.BUTTON_CLICK) 447 | }) 448 | 449 | // Music 450 | 451 | const _toggleAudio = () => { 452 | audioOn = !audioOn 453 | 454 | toggleAudio(!audioOn) 455 | 456 | sideMusicButton.classList.toggle('of', !audioOn) 457 | 458 | defaultMusicButton.textContent = audioOn ? 'MUSIC: ON' : 'MUSIC: OFF' 459 | 460 | localStorage.setItem('king13.mute', audioOn ? '0' : '1') 461 | } 462 | 463 | sideMusicButton.addEventListener('click', _toggleAudio) 464 | defaultMusicButton.addEventListener('click', _toggleAudio) 465 | 466 | // Undo 467 | 468 | sideUndoButton.addEventListener('click', () => { 469 | if (ended) return 470 | 471 | if (popState()) { 472 | vacatedLast = vacated 473 | // spawnedLast = spawned 474 | kingVacatedLast = kingVacated 475 | 476 | renderBoard(ShortBool.TRUE) 477 | 478 | localStorage.setItem('king13.stack', JSON.stringify(stack)) 479 | 480 | sound(SoundEffect.BUTTON_CLICK) 481 | } 482 | }) 483 | 484 | // Share 485 | 486 | endingShareButton.addEventListener('click', () => { 487 | shareTwitter(highestValue > Settings.kingValue ? 'SUCCESS' : 'DEFEAT', getScore()) 488 | 489 | step() 490 | }) 491 | } 492 | 493 | const ending = () => { 494 | const menus = document.querySelectorAll('.u') 495 | const endingMenu = menus[1]! 496 | 497 | const title = endingMenu.querySelector('.ti')! 498 | title.textContent = highestValue > Settings.kingValue ? 'SUCCESS' : 'DEFEAT' 499 | 500 | const score = endingMenu.querySelector('.sc')! 501 | score.textContent = 'Score: ' + getScore() 502 | 503 | endingMenu.classList.remove('h') 504 | 505 | sound(highestValue > Settings.kingValue ? SoundEffect.WIN : SoundEffect.DISCONNECT) 506 | } 507 | -------------------------------------------------------------------------------- /typescript/share.ts: -------------------------------------------------------------------------------- 1 | /** This file is part of King Thirteen. 2 | * https://github.com/mvasilkov/board2024 3 | * @license Proprietary | Copyright (c) 2024 Mark Vasilkov 4 | */ 5 | 'use strict' 6 | 7 | export const shareTwitter = (succdef: 'SUCCESS' | 'DEFEAT', score: number) => { 8 | const year = new Date().getFullYear() 9 | const host = year > 2024 ? 'js13kgames.com' : 'dev.js13kgames.com' 10 | 11 | // https://developer.x.com/en/docs/x-for-websites/tweet-button/overview 12 | const intentUrl = 'https://twitter.com/intent/tweet' 13 | const text = `${succdef}! I scored ${score} in King Thirteen!` 14 | const url = `https://${host}/2024/games/king-thirteen` 15 | const hashtags = 'KingThirteen,js13k' 16 | const via = 'mvasilkov' 17 | 18 | const finalUrl = `${intentUrl}?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}&hashtags=${hashtags}&via=${via}` 19 | window.open(finalUrl, '_blank') 20 | } 21 | --------------------------------------------------------------------------------