├── .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('', '<\\x2f')
52 |
53 | # https://html.spec.whatwg.org/multipage/parsing.html#rawtext-state
54 | assert '' not in app_css
55 | # https://html.spec.whatwg.org/multipage/parsing.html#script-data-state
56 | assert '' not in app_js
57 | assert '')
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 |
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 |
--------------------------------------------------------------------------------