Compare commits
69 Commits
update-11-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a02809ef25 | ||
|
|
260b1425bd | ||
|
|
c531b92884 | ||
|
|
3e77d2afde | ||
|
|
74747b7f16 | ||
|
|
825c02db58 | ||
|
|
87c8280b03 | ||
|
|
686a648dcd | ||
|
|
83e4c9932c | ||
|
|
ef8f5a700d | ||
|
|
0b57a48de3 | ||
|
|
0891a53da1 | ||
|
|
ca287ea8eb | ||
|
|
bd1bdd9966 | ||
|
|
6409983705 | ||
|
|
9d9d560b7a | ||
|
|
282b998d17 | ||
|
|
6372e79204 | ||
|
|
d627db31fb | ||
|
|
5296e19e86 | ||
|
|
242ff60a9d | ||
|
|
4878d7d2c0 | ||
|
|
2792089465 | ||
|
|
5c90d3ad09 | ||
|
|
87cf04909a | ||
|
|
a70b5a9700 | ||
|
|
125531cfbe | ||
|
|
3bcf9cbb71 | ||
|
|
8f936d5ed2 | ||
|
|
0b8e59a7e9 | ||
|
|
372a0deee2 | ||
|
|
e07cdc8904 | ||
|
|
42dc0b462b | ||
|
|
548a3116b2 | ||
|
|
7f7563952f | ||
|
|
ba96b6b6f6 | ||
|
|
3d742ac443 | ||
|
|
686db54fa0 | ||
|
|
935d3de744 | ||
|
|
f193477ce3 | ||
|
|
329993b15d | ||
|
|
08a539b67d | ||
|
|
29ae032914 | ||
|
|
b4e3b17971 | ||
|
|
0f108f7797 | ||
|
|
22405f5770 | ||
|
|
1e3525663f | ||
|
|
df838f9c37 | ||
|
|
c567962309 | ||
|
|
b9a3412ab1 | ||
|
|
acc8cd247a | ||
|
|
461e26ea5d | ||
|
|
eadd999bcb | ||
|
|
a69d67556f | ||
|
|
4861e181ae | ||
|
|
00454429a4 | ||
|
|
dd7366c7d4 | ||
|
|
750a430fa8 | ||
|
|
5f184795cb | ||
|
|
6382f6bd1f | ||
|
|
7f03aac7e4 | ||
|
|
97a459c748 | ||
|
|
46d5aed44c | ||
|
|
752e987fd3 | ||
|
|
ed92eca026 | ||
|
|
ffd9d7d807 | ||
|
|
329013797f | ||
|
|
9f3745ade6 | ||
|
|
23abd86966 |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
|
@ -2,14 +2,14 @@
|
||||||
"name": "Tableturf Battle",
|
"name": "Tableturf Battle",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "external/android-chrome-192x192.png",
|
"src": "external/android-chrome-192x192.webp",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/webp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "external/android-chrome-512x512.png",
|
"src": "external/android-chrome-512x512.webp",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/webp"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme_color": "#0c92f2",
|
"theme_color": "#0c92f2",
|
||||||
|
|
|
||||||
4
TableturfBattleClient/assets/wifi-off.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- This file is part of Bootstrap, available under the MIT License. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-wifi-off" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.706 3.294A12.545 12.545 0 0 0 8 3C5.259 3 2.723 3.882.663 5.379a.485.485 0 0 0-.048.736.518.518 0 0 0 .668.05A11.448 11.448 0 0 1 8 4c.63 0 1.249.05 1.852.148l.854-.854zM8 6c-1.905 0-3.68.56-5.166 1.526a.48.48 0 0 0-.063.745.525.525 0 0 0 .652.065 8.448 8.448 0 0 1 3.51-1.27L8 6zm2.596 1.404.785-.785c.63.24 1.227.545 1.785.907a.482.482 0 0 1 .063.745.525.525 0 0 1-.652.065 8.462 8.462 0 0 0-1.98-.932zM8 10l.933-.933a6.455 6.455 0 0 1 2.013.637c.285.145.326.524.1.75l-.015.015a.532.532 0 0 1-.611.09A5.478 5.478 0 0 0 8 10zm4.905-4.905.747-.747c.59.3 1.153.645 1.685 1.03a.485.485 0 0 1 .047.737.518.518 0 0 1-.668.05 11.493 11.493 0 0 0-1.811-1.07zM9.02 11.78c.238.14.236.464.04.66l-.707.706a.5.5 0 0 1-.707 0l-.707-.707c-.195-.195-.197-.518.04-.66A1.99 1.99 0 0 1 8 11.5c.374 0 .723.102 1.021.28zm4.355-9.905a.53.53 0 0 1 .75.75l-10.75 10.75a.53.53 0 0 1-.75-.75l10.75-10.75z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -9,8 +9,8 @@
|
||||||
</script>
|
</script>
|
||||||
<title>Tableturf Battle</title>
|
<title>Tableturf Battle</title>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/external/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="assets/external/apple-touch-icon.png">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/external/favicon-32x32.png">
|
<link rel="icon" type="image/webp" sizes="32x32" href="assets/external/favicon-32x32.webp">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/external/favicon-16x16.png">
|
<link rel="icon" type="image/webp" sizes="16x16" href="assets/external/favicon-16x16.webp">
|
||||||
<link rel="manifest" href="assets/site.webmanifest">
|
<link rel="manifest" href="assets/site.webmanifest">
|
||||||
<link rel="stylesheet" href="tableturf.css"/>
|
<link rel="stylesheet" href="tableturf.css"/>
|
||||||
<script src="config/config.js"></script>
|
<script src="config/config.js"></script>
|
||||||
|
|
@ -30,8 +30,27 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="noJSPage">This application requires JavaScript.</div>
|
<div id="noJSPage">This application requires JavaScript.</div>
|
||||||
|
<svg id="cardDisplayAssets">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="cardBorder">
|
||||||
|
<rect x="19" y="20" width="404" height="576" rx="18" ry="18"/>
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id='rareGradient' gradientUnits="userSpaceOnUse" x1="29.2%" y1='21.5%' x2="55.5%" y2="15.7%" spreadMethod='reflect'>
|
||||||
|
<stop offset='0%' stop-color='#FBFFCC'/>
|
||||||
|
<stop offset='100%' stop-color='#E0AE12'/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id='freshGradient' gradientUnits="userSpaceOnUse" x1="17.5%" y1="-2.5%" x2="83.5%" y2="32%">
|
||||||
|
<stop offset='0%' stop-color='#FF93DD'/>
|
||||||
|
<stop offset='20%' stop-color='#FEF499'/>
|
||||||
|
<stop offset='50%' stop-color='#C9448A'/>
|
||||||
|
<stop offset='75%' stop-color='#1EFBC3'/>
|
||||||
|
<stop offset='95%' stop-color='#FD97DB'/>
|
||||||
|
<stop offset='100%' stop-color='#FFBAC2'/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
<div id="preGamePage" hidden>
|
<div id="preGamePage" hidden>
|
||||||
<div id="logoBanner"><img title="Tableturf Battle" alt="Tableturf Battle logo" id="logo" src="assets/external/logo.png"></div>
|
<div id="logoBanner"><img title="Tableturf Battle" alt="Tableturf Battle logo" id="logo" src="assets/external/logo.webp"></div>
|
||||||
<h1>Tableturf Battle</h1>
|
<h1>Tableturf Battle</h1>
|
||||||
<form id="preGameForm">
|
<form id="preGameForm">
|
||||||
<p><label for="nameBox">Choose a nickname: <input type="text" id="nameBox" required minlength="1" maxlength="20"/></label></p>
|
<p><label for="nameBox">Choose a nickname: <input type="text" id="nameBox" required minlength="1" maxlength="20"/></label></p>
|
||||||
|
|
@ -57,6 +76,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<a id="preGameDeckEditorButton" href="deckeditor">Edit decks</a> |
|
<a id="preGameDeckEditorButton" href="deckeditor">Edit decks</a> |
|
||||||
|
<a id="preGameGalleryButton" href="cardlist">Card list</a> |
|
||||||
<a id="preGameReplayButton" href="replay">Replay</a> |
|
<a id="preGameReplayButton" href="replay">Replay</a> |
|
||||||
<a id="preGameSettingsButton" href="settings">Settings</a> |
|
<a id="preGameSettingsButton" href="settings">Settings</a> |
|
||||||
<a id="preGameHelpButton" href="help">Help</a>
|
<a id="preGameHelpButton" href="help">Help</a>
|
||||||
|
|
@ -75,22 +95,31 @@
|
||||||
<p>Other players can join using a link to this page.<br/>
|
<p>Other players can join using a link to this page.<br/>
|
||||||
<button type="button" id="shareLinkButton">Share link</button><button type="button" id="showQrCodeButton">Show QR code</button></p>
|
<button type="button" id="shareLinkButton">Share link</button><button type="button" id="showQrCodeButton">Show QR code</button></p>
|
||||||
<ul id="playerList"></ul>
|
<ul id="playerList"></ul>
|
||||||
|
<h3>Game rules</h3>
|
||||||
<label for="lobbyTimeLimitBox">
|
<label for="lobbyTimeLimitBox">
|
||||||
Turn time limit:
|
Turn time limit:
|
||||||
<input type="number" id="lobbyTimeLimitBox" min="10" max="120" step="10" placeholder="None"/>
|
<input type="number" id="lobbyTimeLimitBox" min="10" max="120" step="10" placeholder="None"/>
|
||||||
<span id="lobbyTimeLimitUnit">seconds</span>
|
<span id="lobbyTimeLimitUnit">seconds</span>
|
||||||
</label>
|
</label>
|
||||||
<div id="lobbySelectedStageSection" hidden>
|
<label for="lobbyAllowUpcomingCardsBox">
|
||||||
<h3>Stage</h3>
|
<input type="checkbox" id="lobbyAllowUpcomingCardsBox"/> Allow upcoming cards
|
||||||
</div>
|
</label>
|
||||||
|
<label for="lobbyAllowCustomCardsBox">
|
||||||
|
<input type="checkbox" id="lobbyAllowCustomCardsBox"/> Allow custom cards
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
<section id="lobbySelectedStageSection">
|
||||||
|
<h3>Stage</h3>
|
||||||
</section>
|
</section>
|
||||||
<section id="lobbyStageSection" hidden>
|
<section id="lobbyStageSection" hidden>
|
||||||
<h3>Vote for the stage.</h3>
|
<form id="stageSelectionForm" hidden>
|
||||||
<form id="stageSelectionForm">
|
<div id="stageSelectionFormHeader">
|
||||||
<div class="submitButtonContainer">
|
<h3 id="stagePrompt">Vote for the stage.</h3>
|
||||||
<button type="submit" id="submitStageButton">Submit</button>
|
<div class="submitButtonContainer">
|
||||||
<div class="loadingContainer" hidden>
|
<button type="submit" id="submitStageButton" disabled>Submit</button>
|
||||||
<div class="loadingSpinner"></div>
|
<div class="loadingContainer" hidden>
|
||||||
|
<div class="loadingSpinner"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stageList">
|
<div id="stageList">
|
||||||
|
|
@ -98,6 +127,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="stageListLoadingSection"><div class="loadingSpinner"></div> Loading stages...</div>
|
<div id="stageListLoadingSection"><div class="loadingSpinner"></div> Loading stages...</div>
|
||||||
</form>
|
</form>
|
||||||
|
<form id="strikeOrderSelectionForm" hidden>
|
||||||
|
<h3 id="stagePrompt">Do you want to strike first or second?</h3>
|
||||||
|
<button type="submit" data-strike-index="0">Strike first</button>
|
||||||
|
<button type="submit" data-strike-index="1">Strike second</button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<div id="lobbyDeckSection" hidden>
|
<div id="lobbyDeckSection" hidden>
|
||||||
<h3>Choose your deck.</h3>
|
<h3>Choose your deck.</h3>
|
||||||
|
|
@ -427,7 +461,12 @@
|
||||||
<section id="deckEditorDeckViewSection">
|
<section id="deckEditorDeckViewSection">
|
||||||
<a id="deckViewBackButton" href="#">Back</a>
|
<a id="deckViewBackButton" href="#">Back</a>
|
||||||
<h3 id="deckName"> </h3>
|
<h3 id="deckName"> </h3>
|
||||||
<div id="deckListToolbar">
|
<button id="deckViewMenuButton" class="menuButton">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-list" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="deckViewMenu" class="menu">
|
||||||
<button type="button" id="deckListTestButton" disabled>Test</button>
|
<button type="button" id="deckListTestButton" disabled>Test</button>
|
||||||
<button type="button" id="deckExportButton" disabled>Export</button>
|
<button type="button" id="deckExportButton" disabled>Export</button>
|
||||||
<button type="button" id="deckCopyButton" disabled>Copy</button>
|
<button type="button" id="deckCopyButton" disabled>Copy</button>
|
||||||
|
|
@ -436,7 +475,7 @@
|
||||||
<button type="button" id="deckRenameButton" disabled>Rename</button>
|
<button type="button" id="deckRenameButton" disabled>Rename</button>
|
||||||
<button type="button" id="deckDeleteButton" class="danger" disabled>Delete</button>
|
<button type="button" id="deckDeleteButton" class="danger" disabled>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="deckSizeContainer">Total <div id="deckViewSize">0</div></div>
|
<div class="deckSizeContainer"><div class="sizeLabel">Total</div> <div id="deckViewSize">0</div></div>
|
||||||
<div id="deckCardListView">
|
<div id="deckCardListView">
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -468,6 +507,12 @@
|
||||||
<input type="radio" name="deckSleeves" id="deckSleeve22" value="22"/><label for="deckSleeve22"></label>
|
<input type="radio" name="deckSleeves" id="deckSleeve22" value="22"/><label for="deckSleeve22"></label>
|
||||||
<input type="radio" name="deckSleeves" id="deckSleeve23" value="23"/><label for="deckSleeve23"></label>
|
<input type="radio" name="deckSleeves" id="deckSleeve23" value="23"/><label for="deckSleeve23"></label>
|
||||||
<input type="radio" name="deckSleeves" id="deckSleeve24" value="24"/><label for="deckSleeve24"></label>
|
<input type="radio" name="deckSleeves" id="deckSleeve24" value="24"/><label for="deckSleeve24"></label>
|
||||||
|
<input type="radio" name="deckSleeves" id="deckSleeve25" value="25"/><label for="deckSleeve25"></label>
|
||||||
|
<input type="radio" name="deckSleeves" id="deckSleeve26" value="26"/><label for="deckSleeve26"></label>
|
||||||
|
<input type="radio" name="deckSleeves" id="deckSleeve27" value="27"/><label for="deckSleeve27"></label>
|
||||||
|
<input type="radio" name="deckSleeves" id="deckSleeve28" value="28"/><label for="deckSleeve28"></label>
|
||||||
|
<input type="radio" name="deckSleeves" id="deckSleeve29" value="29"/><label for="deckSleeve29"></label>
|
||||||
|
<input type="radio" name="deckSleeves" id="deckSleeve30" value="30"/><label for="deckSleeve30"></label>
|
||||||
</section>
|
</section>
|
||||||
<section id="deckSleevesFormButtons">
|
<section id="deckSleevesFormButtons">
|
||||||
<button type="submit" id="deckSleevesOkButton">Select</button>
|
<button type="submit" id="deckSleevesOkButton">Select</button>
|
||||||
|
|
@ -490,7 +535,7 @@
|
||||||
Import from screenshots
|
Import from screenshots
|
||||||
</label>
|
</label>
|
||||||
<section id="deckImportScreenshotSection">
|
<section id="deckImportScreenshotSection">
|
||||||
<input type="file" id="deckImportFileBox" accept="image/png,image/jpeg,image/webp,image/bmp" multiple autocomplete="off"/>
|
<input type="file" id="deckImportFileBox" accept="image/*" multiple autocomplete="off"/>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" id="deckImportScreenshotInstructionsButton">Show instructions</button>
|
<button type="button" id="deckImportScreenshotInstructionsButton">Show instructions</button>
|
||||||
<div id="deckImportScreenshotInstructions" hidden>
|
<div id="deckImportScreenshotInstructions" hidden>
|
||||||
|
|
@ -539,13 +584,18 @@
|
||||||
<div id="deckEditPage" hidden>
|
<div id="deckEditPage" hidden>
|
||||||
<section id="deckEditorDeckEditPage">
|
<section id="deckEditorDeckEditPage">
|
||||||
<h3 id="deckName2">Deck</h3>
|
<h3 id="deckName2">Deck</h3>
|
||||||
<div id="deckEditToolbar">
|
<button id="deckEditMenuButton" class="menuButton">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-list" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="deckEditMenu" class="menu">
|
||||||
<button type="button" id="deckSortButton">Sort</button>
|
<button type="button" id="deckSortButton">Sort</button>
|
||||||
<button type="button" id="deckTestButton">Test</button>
|
<button type="button" id="deckTestButton">Test</button>
|
||||||
<button type="button" id="deckSaveButton">Save</button>
|
<button type="button" id="deckSaveButton">Save</button>
|
||||||
<button type="button" id="deckCancelButton">Cancel</button>
|
<button type="button" id="deckCancelButton">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="deckSizeContainer">Total <div id="deckEditSize">0</div></div>
|
<div class="deckSizeContainer"><div class="sizeLabel">Total</div> <div id="deckEditSize">0</div></div>
|
||||||
<div id="deckCardListEdit">
|
<div id="deckCardListEdit">
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -560,9 +610,63 @@
|
||||||
<input type="text" id="cardListFilterBox" placeholder="Filter"/>
|
<input type="text" id="cardListFilterBox" placeholder="Filter"/>
|
||||||
</div>
|
</div>
|
||||||
<div id="cardList" class="cardListGrid"></div>
|
<div id="cardList" class="cardListGrid"></div>
|
||||||
|
<button id="deckEditorRemoveButton">Cut from deck</button>
|
||||||
</section>
|
</section>
|
||||||
<div id="deckEditorCardListBackdrop"></div>
|
<div id="deckEditorCardListBackdrop"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="galleryPage" hidden>
|
||||||
|
<header>
|
||||||
|
<a id="galleryBackButton" href=".">Back</a>
|
||||||
|
<label for="gallerySortBox">
|
||||||
|
Sort by
|
||||||
|
<select id="gallerySortBox" autocomplete="off"></select>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="galleryFilterBox" placeholder="Filter"/>
|
||||||
|
<button id="galleryNewCustomCardButton">New custom card</button>
|
||||||
|
<label for="galleryChecklistBox"><input type="checkbox" id="galleryChecklistBox" autocomplete="off"/>Checklist</label>
|
||||||
|
Bits to complete: <span id="bitsToCompleteField"></span>
|
||||||
|
</header>
|
||||||
|
<main id="galleryCardList">
|
||||||
|
</main>
|
||||||
|
<dialog id="galleryCardDialog">
|
||||||
|
<div id="galleryCardEditor" hidden>
|
||||||
|
<textarea type="text" id="galleryCardEditorName" placeholder="Card name"></textarea>
|
||||||
|
<div id="galleryCardEditorGrid"></div>
|
||||||
|
<div id="galleryCardEditorImageToolbar" class="galleryCardEditorToolbar">
|
||||||
|
<input type="file" id="galleryCardEditorImageFile" accept="image/*" autocomplete="off"/>
|
||||||
|
<button id="galleryCardEditorImageSelectButton">Choose image</button>
|
||||||
|
<button id="galleryCardEditorImageClearButton">Clear</button>
|
||||||
|
<footer>(The image will not be seen by other players.)</footer>
|
||||||
|
</div>
|
||||||
|
<div id="galleryCardEditorColoursToolbar" class="galleryCardEditorToolbar">
|
||||||
|
Colours:
|
||||||
|
<input type="color" id="galleryCardEditorColour1"/>
|
||||||
|
<input type="color" id="galleryCardEditorColour2"/>
|
||||||
|
<select id="galleryCardEditorColourPresetBox"></select>
|
||||||
|
</div>
|
||||||
|
<div id="galleryCardEditorRarityToolbar" class="galleryCardEditorToolbar">
|
||||||
|
<label for="galleryCardEditorRarityBox">Rarity: <select id="galleryCardEditorRarityBox"></select></label>
|
||||||
|
</div>
|
||||||
|
<div id="galleryCardEditorSpecialCost">
|
||||||
|
<label for="galleryCardEditorSpecialCostDefaultBox"><input type="checkbox" id="galleryCardEditorSpecialCostDefaultBox" checked/>Default</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog">
|
||||||
|
<button type="button" id="galleryCardEditorEditButton">Edit</button>
|
||||||
|
<button type="button" id="galleryCardEditorSubmitButton">Save</button>
|
||||||
|
<button type="button" id="galleryCardEditorDeleteButton">Delete</button>
|
||||||
|
<button type="button" id="galleryCardEditorSnapshotButton">Snapshot</button>
|
||||||
|
<button type="submit" id="galleryCardEditorCancelButton">Cancel</button>
|
||||||
|
</form>
|
||||||
|
<dialog id="galleryCardDeleteDialog">
|
||||||
|
<div></div>
|
||||||
|
<form method="dialog">
|
||||||
|
<button type="submit" id="galleryCardEditorDeleteYesButton">Delete</button>
|
||||||
|
<button type="submit">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
<dialog id="testStageSelectionDialog">
|
<dialog id="testStageSelectionDialog">
|
||||||
<h3>Select a stage.</h3>
|
<h3>Select a stage.</h3>
|
||||||
<form id="testStageSelectionForm" method="dialog">
|
<form id="testStageSelectionForm" method="dialog">
|
||||||
|
|
@ -602,6 +706,57 @@
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="gameSetupAllowUpcomingCardsBox"><input type="checkbox" id="gameSetupAllowUpcomingCardsBox" checked/> Allow upcoming cards</label>
|
||||||
|
<label for="gameSetupAllowCustomCardsBox"><input type="checkbox" id="gameSetupAllowCustomCardsBox"/> Allow custom cards</label>
|
||||||
|
</p>
|
||||||
|
<p>Stage selection:</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><label for="stageSelectionRuleFirstBox">First battle:</label></td>
|
||||||
|
<td>
|
||||||
|
<select id="stageSelectionRuleFirstBox">
|
||||||
|
<option value="Vote">Vote</option>
|
||||||
|
<option value="Random">Random/Fixed</option>
|
||||||
|
<option value="Strike">Strike</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="stageSelectionRuleAfterWinBox">After a win:</label></td>
|
||||||
|
<td>
|
||||||
|
<select id="stageSelectionRuleAfterWinBox">
|
||||||
|
<option value="Inherit">Same method as the first battle</option>
|
||||||
|
<option value="Same">Repeat stage of the last battle</option>
|
||||||
|
<option value="Vote">Vote</option>
|
||||||
|
<option value="Random">Random/Fixed</option>
|
||||||
|
<option value="Counterpick">Counterpick</option>
|
||||||
|
<option value="Strike">Strike</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="stageSelectionRuleAfterDrawBox">After a draw:</label></td>
|
||||||
|
<td>
|
||||||
|
<select id="stageSelectionRuleAfterDrawBox">
|
||||||
|
<option value="Inherit">Same method as the first battle</option>
|
||||||
|
<option value="Same">Repeat stage of the last battle</option>
|
||||||
|
<option value="Vote">Vote</option>
|
||||||
|
<option value="Random">Random/Fixed</option>
|
||||||
|
<option value="Strike">Strike</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<label for="gameSetupForceSameDeckAfterDrawBox"><input type="checkbox" id="gameSetupForceSameDeckAfterDrawBox"/> Force same decks</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>Stage switch:</p>
|
||||||
|
<div id="stageSwitch"></div>
|
||||||
|
<label for="gameSetupSpectateBox"><input type="checkbox" id="gameSetupSpectateBox"/> Create as a spectator</label>
|
||||||
<p>
|
<p>
|
||||||
<button type="submit" id="gameSetupSubmitButton">Create room</button>
|
<button type="submit" id="gameSetupSubmitButton">Create room</button>
|
||||||
<button type="submit">Back</button>
|
<button type="submit">Back</button>
|
||||||
|
|
@ -660,7 +815,37 @@
|
||||||
<div id="settingsMessage"></div>
|
<div id="settingsMessage"></div>
|
||||||
<form id="settingsDialogForm" method="dialog">
|
<form id="settingsDialogForm" method="dialog">
|
||||||
<p><label for="optionsColourLock"><input type="checkbox" id="optionsColourLock" checked/> Colour lock</label></p>
|
<p><label for="optionsColourLock"><input type="checkbox" id="optionsColourLock" checked/> Colour lock</label></p>
|
||||||
|
<p>
|
||||||
|
Preferred colours –
|
||||||
|
<label for="optionsColourGoodBox">Good guy:
|
||||||
|
<select id="optionsColourGoodBox">
|
||||||
|
<option value="red">Red</option>
|
||||||
|
<option value="orange">Orange</option>
|
||||||
|
<option value="yellow">Yellow</option>
|
||||||
|
<option value="limegreen">Lime</option>
|
||||||
|
<option value="green">Green</option>
|
||||||
|
<option value="turquoise">Turquoise</option>
|
||||||
|
<option value="blue">Blue</option>
|
||||||
|
<option value="purple">Purple</option>
|
||||||
|
<option value="magenta">Magenta</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label for="optionsColourBadBox">Bad guy:
|
||||||
|
<select id="optionsColourBadBox">
|
||||||
|
<option value="red">Red</option>
|
||||||
|
<option value="orange">Orange</option>
|
||||||
|
<option value="yellow">Yellow</option>
|
||||||
|
<option value="limegreen">Lime</option>
|
||||||
|
<option value="green">Green</option>
|
||||||
|
<option value="turquoise">Turquoise</option>
|
||||||
|
<option value="blue">Blue</option>
|
||||||
|
<option value="purple">Purple</option>
|
||||||
|
<option value="magenta">Magenta</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
<p><label for="optionsTurnNumberStyle">Turn number style: <select id="optionsTurnNumberStyle"><option value="remaining" selected>Turns remaining</option><option value="absolute">Turn number</option></select></label></p>
|
<p><label for="optionsTurnNumberStyle">Turn number style: <select id="optionsTurnNumberStyle"><option value="remaining" selected>Turns remaining</option><option value="absolute">Turn number</option></select></label></p>
|
||||||
|
<p><label for="optionsSpecialWeaponSorting">Special weapon card sorting: <select id="optionsSpecialWeaponSorting"><option value="First" selected>First</option><option value="Last">Last</option><option value="InOrder">In order</option></select></label></p>
|
||||||
<button type="submit">Close</button>
|
<button type="submit">Close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,6 @@ class Board {
|
||||||
}
|
}
|
||||||
const cell = this.cells[x2][y2];
|
const cell = this.cells[x2][y2];
|
||||||
cell.classList.add('hover');
|
cell.classList.add('hover');
|
||||||
cell.classList.add(`hover${this.playerIndex + 1}`);
|
|
||||||
if (!legal)
|
if (!legal)
|
||||||
cell.classList.add('hoverillegal');
|
cell.classList.add('hoverillegal');
|
||||||
if (space == Space.SpecialInactive1)
|
if (space == Space.SpecialInactive1)
|
||||||
|
|
@ -269,11 +268,26 @@ class Board {
|
||||||
|
|
||||||
private internalClearHighlight() {
|
private internalClearHighlight() {
|
||||||
for (const s of this.highlightedCells) {
|
for (const s of this.highlightedCells) {
|
||||||
this.cells[s.x][s.y].setAttribute('class', Space[this.grid[s.x][s.y]] );
|
this.cells[s.x][s.y].classList.remove('hover', 'hoverillegal', 'hoverspecial');
|
||||||
}
|
}
|
||||||
this.highlightedCells.splice(0);
|
this.highlightedCells.splice(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTestHighlight(x: number, y: number, highlight: boolean) {
|
||||||
|
if (highlight)
|
||||||
|
this.cells[x][y].classList.add('testHighlight');
|
||||||
|
else
|
||||||
|
this.cells[x][y].classList.remove('testHighlight');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTestHighlight() {
|
||||||
|
for (let x = 0; x < this.grid.length; x++) {
|
||||||
|
for (let y = 0; y < this.grid[x].length; y++) {
|
||||||
|
this.cells[x][y].classList.remove('testHighlight');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enableInkAnimations() {
|
enableInkAnimations() {
|
||||||
this.table.classList.add('enableInkAnimation');
|
this.table.classList.add('enableInkAnimation');
|
||||||
}
|
}
|
||||||
|
|
@ -370,8 +384,8 @@ class Board {
|
||||||
if (e.pointerType != 'touch') {
|
if (e.pointerType != 'touch') {
|
||||||
if (this.autoHighlight && this.cardPlaying != null) {
|
if (this.autoHighlight && this.cardPlaying != null) {
|
||||||
const offset = this.rotatedMouseOffset;
|
const offset = this.rotatedMouseOffset;
|
||||||
const x = parseInt((e.target as HTMLTableCellElement).dataset.x!) - (this.flip ? Math.ceil(offset.x) : Math.floor(offset.x));
|
const x = parseInt((e.currentTarget as HTMLTableCellElement).dataset.x!) - (this.flip ? Math.ceil(offset.x) : Math.floor(offset.x));
|
||||||
const y = parseInt((e.target as HTMLTableCellElement).dataset.y!) - (this.flip ? Math.ceil(offset.y) : Math.floor(offset.y));
|
const y = parseInt((e.currentTarget as HTMLTableCellElement).dataset.y!) - (this.flip ? Math.ceil(offset.y) : Math.floor(offset.y));
|
||||||
if (x != this.highlightX || y != this.highlightY) {
|
if (x != this.highlightX || y != this.highlightY) {
|
||||||
this.highlightX = x;
|
this.highlightX = x;
|
||||||
this.highlightY = y;
|
this.highlightY = y;
|
||||||
|
|
@ -422,7 +436,6 @@ class Board {
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
this.clearHighlight();
|
|
||||||
this.clearInkAnimations();
|
this.clearInkAnimations();
|
||||||
for (let x = 0; x < this.grid.length; x++) {
|
for (let x = 0; x < this.grid.length; x++) {
|
||||||
for (let y = 0; y < this.grid[x].length; y++) {
|
for (let y = 0; y < this.grid[x].length; y++) {
|
||||||
|
|
@ -432,7 +445,9 @@ class Board {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisplayedSpace(x: number, y: number, newState: Space) {
|
setDisplayedSpace(x: number, y: number, newState: Space) {
|
||||||
|
const isTestHighlight = this.cells[x][y].classList.contains('testHighlight');
|
||||||
this.cells[x][y].setAttribute('class', Space[newState]);
|
this.cells[x][y].setAttribute('class', Space[newState]);
|
||||||
|
if (isTestHighlight) this.cells[x][y].classList.add('testHighlight');
|
||||||
if (this.cells[x][y].childNodes.length > 0) {
|
if (this.cells[x][y].childNodes.length > 0) {
|
||||||
if ((newState & Space.SpecialActive1) != Space.SpecialActive1)
|
if ((newState & Space.SpecialActive1) != Space.SpecialActive1)
|
||||||
this.clearSpecialAnimation(x, y);
|
this.clearSpecialAnimation(x, y);
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,77 @@
|
||||||
class Card {
|
class Card {
|
||||||
number: number;
|
number: number;
|
||||||
altNumber: number | null;
|
altNumber?: number | null;
|
||||||
name: string;
|
readonly name: string;
|
||||||
line1: string | null;
|
readonly line1: string | null;
|
||||||
line2: string | null;
|
readonly line2: string | null;
|
||||||
artFileName: string | null;
|
artFileName?: string | null;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
textScale: number;
|
textScale: number;
|
||||||
inkColour1: Colour;
|
inkColour1: Colour;
|
||||||
inkColour2: Colour;
|
inkColour2: Colour;
|
||||||
rarity: Rarity;
|
rarity: Rarity;
|
||||||
specialCost: number;
|
specialCost: number;
|
||||||
grid: readonly (readonly Space[])[];
|
grid: Space[][];
|
||||||
size: number;
|
size: number;
|
||||||
|
isVariantOf?: number | null;
|
||||||
|
|
||||||
private minX: number;
|
private minX: number;
|
||||||
private minY: number;
|
private minY: number;
|
||||||
private maxX: number;
|
private maxX: number;
|
||||||
private maxY: number;
|
private maxY: number;
|
||||||
|
|
||||||
private static DEFAULT_INK_COLOUR_1: Colour = { r: 116, g: 96, b: 240 };
|
static DEFAULT_INK_COLOUR_1: Colour = { r: 224, g: 242, b: 104 };
|
||||||
private static DEFAULT_INK_COLOUR_2: Colour = { r: 224, g: 242, b: 104 };
|
static DEFAULT_INK_COLOUR_2: Colour = { r: 116, g: 96, b: 240 };
|
||||||
|
private static textScaleCalculationContext: OffscreenCanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
constructor(number: number, altNumber: number | null, name: string, line1: string | null, line2: string | null, artFileName: string | null, textScale: number, inkColour1: Colour, inkColour2: Colour, rarity: Rarity, specialCost: number, grid: Space[][]) {
|
private static getTextScaleCalculationContext() {
|
||||||
|
if (this.textScaleCalculationContext == null) {
|
||||||
|
const canvas = new OffscreenCanvas(256, 256);
|
||||||
|
this.textScaleCalculationContext = canvas.getContext("2d")!;
|
||||||
|
this.textScaleCalculationContext.font = 'bold 72pt "Splatoon 1"';
|
||||||
|
}
|
||||||
|
return this.textScaleCalculationContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
static wrapName(name: string): [line1: string | null, line2: string | null] {
|
||||||
|
// If the user has entered manual line breaks, use those instead of auto-wrapping.
|
||||||
|
const pos = name.indexOf('\n');
|
||||||
|
if (pos >= 0)
|
||||||
|
return [ name.substring(0, pos), name.substring(pos + 1) ];
|
||||||
|
|
||||||
|
const ctx = Card.getTextScaleCalculationContext();
|
||||||
|
const line1Width = ctx.measureText(name).width;
|
||||||
|
if (line1Width <= 700)
|
||||||
|
return [ null, null ];
|
||||||
|
|
||||||
|
// We're going to break the line.
|
||||||
|
let bestPos = 0; let bestWidth = Infinity;
|
||||||
|
for (const m of name.matchAll(/[-\s]/g)) {
|
||||||
|
const pos = m.index! + 1;
|
||||||
|
const width = Math.max(ctx.measureText(name.substring(0, m[0] == ' ' ? pos - 1 : pos)).width, ctx.measureText(name.substring(pos)).width);
|
||||||
|
if (width < bestWidth) {
|
||||||
|
bestPos = pos;
|
||||||
|
bestWidth = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPos > 0
|
||||||
|
? [ name.substring(0, bestPos).trimEnd(), name.substring(bestPos) ]
|
||||||
|
: [ null, null ];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(number: number, name: string, line1: string | null, line2: string | null, inkColour1: Colour, inkColour2: Colour, rarity: Rarity, specialCost: number, grid: Space[][]) {
|
||||||
this.number = number;
|
this.number = number;
|
||||||
this.altNumber = altNumber;
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.line1 = line1;
|
this.line1 = line1;
|
||||||
this.line2 = line2;
|
this.line2 = line2;
|
||||||
this.artFileName = artFileName;
|
|
||||||
this.textScale = textScale;
|
|
||||||
this.inkColour1 = inkColour1;
|
this.inkColour1 = inkColour1;
|
||||||
this.inkColour2 = inkColour2;
|
this.inkColour2 = inkColour2;
|
||||||
this.rarity = rarity;
|
this.rarity = rarity;
|
||||||
this.specialCost = specialCost;
|
this.specialCost = specialCost;
|
||||||
this.grid = grid;
|
this.grid = grid;
|
||||||
|
|
||||||
let size = 0, minX = 3, minY = 3, maxX = 3, maxY = 3;
|
let size = 0, minX = 3, minY = 3, maxX = 3, maxY = 3, hasSpecialSpace = false;
|
||||||
for (let y = 0; y < 8; y++) {
|
for (let y = 0; y < 8; y++) {
|
||||||
for (let x = 0; x < 8; x++) {
|
for (let x = 0; x < 8; x++) {
|
||||||
if (grid[x][y] != Space.Empty) {
|
if (grid[x][y] != Space.Empty) {
|
||||||
|
|
@ -45,6 +80,8 @@ class Card {
|
||||||
minY = Math.min(minY, y);
|
minY = Math.min(minY, y);
|
||||||
maxX = Math.max(maxX, x);
|
maxX = Math.max(maxX, x);
|
||||||
maxY = Math.max(maxY, y);
|
maxY = Math.max(maxY, y);
|
||||||
|
if (grid[x][y] == Space.SpecialInactive1)
|
||||||
|
hasSpecialSpace = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,15 +90,48 @@ class Card {
|
||||||
this.minY = minY;
|
this.minY = minY;
|
||||||
this.maxX = maxX;
|
this.maxX = maxX;
|
||||||
this.maxY = maxY;
|
this.maxY = maxY;
|
||||||
|
if (!specialCost) {
|
||||||
|
this.specialCost =
|
||||||
|
size <= 3 ? 1
|
||||||
|
: size <= 5 ? 2
|
||||||
|
: size <= 8 ? 3
|
||||||
|
: size <= 11 ? 4
|
||||||
|
: size <= 15 ? 5
|
||||||
|
: 6;
|
||||||
|
if (!hasSpecialSpace && this.specialCost > 3)
|
||||||
|
this.specialCost = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = Card.getTextScaleCalculationContext();
|
||||||
|
const line1Width = ctx.measureText(line1 ?? name).width;
|
||||||
|
const line2Width = line2 != null ? ctx.measureText(line2).width : 0;
|
||||||
|
const width = Math.max(line1Width, line2Width);
|
||||||
|
this.textScale = width <= 700 ? 1 : 700 / width;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJson(obj: any) {
|
static fromJson(obj: any) {
|
||||||
return cardDatabase.cards && cardDatabase.isValidCardNumber(obj.number)
|
if (cardDatabase.cards && cardDatabase.isValidCardNumber(obj.number)) return cardDatabase.get(obj.number);
|
||||||
? cardDatabase.get(obj.number)
|
const card = new Card(obj.number, obj.name, obj.line1, obj.line2, obj.inkColour1 ?? this.DEFAULT_INK_COLOUR_1, obj.inkColour2 ?? this.DEFAULT_INK_COLOUR_2, obj.rarity, obj.specialCost, obj.grid);
|
||||||
: new Card(obj.number, obj.altNumber ?? null, obj.name, obj.line1 ?? null, obj.line2 ?? null, obj.artFileName ?? null, obj.textScale ?? 1, obj.inkColour1 ?? this.DEFAULT_INK_COLOUR_1, obj.inkColour2 ?? this.DEFAULT_INK_COLOUR_2, obj.rarity, obj.specialCost, obj.grid);
|
card.altNumber = obj.altNumber ?? null;
|
||||||
|
card.artFileName = obj.artFileName ?? null;
|
||||||
|
card.imageUrl = obj.imageUrl ?? null;
|
||||||
|
card.isVariantOf = obj.isVariantOf ?? null;
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isUpcoming() { return this.number < 0; }
|
isTheSameAs(jsonCard: Card) {
|
||||||
|
if (this.name != jsonCard.name) return false;
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
if (this.grid[x][y] != jsonCard.grid[x][y]) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCustom() { return this.number <= UNSAVED_CUSTOM_CARD_INDEX; }
|
||||||
|
get isUpcoming() { return this.number < 0 && !this.isCustom; }
|
||||||
|
get isSpecialWeapon() { return this.specialCost == 3 && this.size == 12 };
|
||||||
|
|
||||||
getSpace(x: number, y: number, rotation: number) {
|
getSpace(x: number, y: number, rotation: number) {
|
||||||
switch (rotation & 3) {
|
switch (rotation & 3) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/// <reference path="CheckButton.ts"/>
|
/// <reference path="CheckButton.ts"/>
|
||||||
|
|
||||||
class CardButton extends CheckButton {
|
class CardButton extends CheckButton implements ICardElement {
|
||||||
|
readonly element: HTMLButtonElement;
|
||||||
private static idNumber = 0;
|
private static idNumber = 0;
|
||||||
|
|
||||||
readonly card: Card;
|
readonly card: Card;
|
||||||
|
|
@ -10,9 +11,11 @@ class CardButton extends CheckButton {
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.classList.add('cardButton');
|
button.classList.add('cardButton');
|
||||||
button.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
|
button.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
|
||||||
if (card.number < 0) button.classList.add('upcoming');
|
if (card.isCustom) button.classList.add('custom');
|
||||||
|
else if (card.isUpcoming) button.classList.add('upcoming');
|
||||||
button.dataset.cardNumber = card.number.toString();
|
button.dataset.cardNumber = card.number.toString();
|
||||||
super(button);
|
super(button);
|
||||||
|
this.element = button;
|
||||||
|
|
||||||
this.card = card;
|
this.card = card;
|
||||||
|
|
||||||
|
|
@ -34,7 +37,7 @@ class CardButton extends CheckButton {
|
||||||
|
|
||||||
let el2 = document.createElement('div');
|
let el2 = document.createElement('div');
|
||||||
el2.classList.add('cardNumber');
|
el2.classList.add('cardNumber');
|
||||||
el2.innerText = card.number >= 0 ? `No. ${card.number}` : 'Upcoming';
|
el2.innerText = card.number >= 0 ? `No. ${card.number}` : card.isCustom ? 'Custom' : 'Upcoming';
|
||||||
row.appendChild(el2);
|
row.appendChild(el2);
|
||||||
|
|
||||||
el2 = document.createElement('div');
|
el2 = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
const cardDatabase = {
|
const cardDatabase = {
|
||||||
|
/** The list of official cards, or null if the database has not yet been loaded. */
|
||||||
cards: null as Card[] | null,
|
cards: null as Card[] | null,
|
||||||
|
/** The list of custom cards added by the user. */
|
||||||
|
customCards: [ ] as Card[],
|
||||||
|
/** The list of custom cards used in the current game that were not added by the user. */
|
||||||
|
receivedCustomCards: [ ] as Card[],
|
||||||
|
customCardsModified: false,
|
||||||
|
/** The number of official cards, and the highest card number among official cards. */
|
||||||
lastOfficialCardNumber: 0,
|
lastOfficialCardNumber: 0,
|
||||||
|
|
||||||
_byAltNumber: [ ] as Card[],
|
_byAltNumber: [ ] as Card[],
|
||||||
|
|
||||||
// Upcoming cards are identified with a negative number, as their actual numbers aren't known until their release.
|
// Upcoming cards are identified with a negative number, as their actual numbers aren't known until their release.
|
||||||
|
|
@ -10,76 +18,101 @@ const cardDatabase = {
|
||||||
if (number > 0) {
|
if (number > 0) {
|
||||||
number--;
|
number--;
|
||||||
if (number < cardDatabase.lastOfficialCardNumber) return cardDatabase.cards[number];
|
if (number < cardDatabase.lastOfficialCardNumber) return cardDatabase.cards[number];
|
||||||
|
} else if (number <= RECEIVED_CUSTOM_CARD_START) {
|
||||||
|
const card = cardDatabase.receivedCustomCards[RECEIVED_CUSTOM_CARD_START - number];
|
||||||
|
if (card) return card;
|
||||||
|
} else if (number <= CUSTOM_CARD_START) {
|
||||||
|
const card = cardDatabase.customCards[CUSTOM_CARD_START - number];
|
||||||
|
if (card) return card;
|
||||||
} else if (number < 0) {
|
} else if (number < 0) {
|
||||||
const card = cardDatabase._byAltNumber[-number];
|
const card = cardDatabase._byAltNumber[-number];
|
||||||
if (card) return card;
|
if (card) return card;
|
||||||
}
|
}
|
||||||
throw new RangeError(`No card with number ${number}`);
|
throw new RangeError(`No card with number ${number}`);
|
||||||
},
|
},
|
||||||
isValidCardNumber(number: number) {
|
isValidOfficialCardNumber(number: number) {
|
||||||
return number > 0 ? number <= cardDatabase.lastOfficialCardNumber : cardDatabase._byAltNumber[-number] != undefined;
|
return number > 0 ? number <= cardDatabase.lastOfficialCardNumber : cardDatabase._byAltNumber[-number] != undefined;
|
||||||
},
|
},
|
||||||
|
isValidCardNumber(number: number) {
|
||||||
|
return number > 0 ? number <= cardDatabase.lastOfficialCardNumber
|
||||||
|
: number <= RECEIVED_CUSTOM_CARD_START ? RECEIVED_CUSTOM_CARD_START - number < cardDatabase.receivedCustomCards.length
|
||||||
|
: number <= CUSTOM_CARD_START ? CUSTOM_CARD_START - number < cardDatabase.customCards.length
|
||||||
|
: cardDatabase._byAltNumber[-number] != undefined;
|
||||||
|
},
|
||||||
loadAsync() {
|
loadAsync() {
|
||||||
return new Promise<Card[]>((resolve, reject) => {
|
return new Promise<Card[]>((resolve, reject) => {
|
||||||
if (cardDatabase.cards != null) {
|
function afterFontLoaded() {
|
||||||
resolve(cardDatabase.cards);
|
if (cardDatabase.cards != null) {
|
||||||
return;
|
resolve(cardDatabase.cards);
|
||||||
}
|
return;
|
||||||
const cardListRequest = new XMLHttpRequest();
|
|
||||||
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
|
|
||||||
cardListRequest.addEventListener('load', e => {
|
|
||||||
const cards: Card[] = [ ];
|
|
||||||
if (cardListRequest.status == 200) {
|
|
||||||
const s = cardListRequest.responseText;
|
|
||||||
const response = JSON.parse(s) as object[];
|
|
||||||
for (const o of response) {
|
|
||||||
const card = Card.fromJson(o);
|
|
||||||
cards.push(card);
|
|
||||||
cardDatabase.lastOfficialCardNumber = Math.max(cardDatabase.lastOfficialCardNumber, card.number);
|
|
||||||
if (card.number < 0) cardDatabase._byAltNumber[-card.number] = card;
|
|
||||||
else if (card.altNumber != null && card.altNumber < 0) cardDatabase._byAltNumber[-card.altNumber] = card;
|
|
||||||
}
|
|
||||||
cardDatabase.cards = cards;
|
|
||||||
|
|
||||||
if (window.location.protocol == 'file:') {
|
|
||||||
// If debugging locally, just read the files from the assets directory.
|
|
||||||
for (const card of cardDatabase.cards!) {
|
|
||||||
card.imageUrl = `assets/external/card/${card.artFileName}.webp`
|
|
||||||
}
|
|
||||||
resolve(cards);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Otherwise, download and extract card images from a .tar package.
|
|
||||||
const imagesRequest = new XMLHttpRequest();
|
|
||||||
imagesRequest.responseType = 'arraybuffer';
|
|
||||||
imagesRequest.open('GET', 'assets/external/card.tar');
|
|
||||||
imagesRequest.addEventListener('load', () => {
|
|
||||||
if (imagesRequest.status == 200 && imagesRequest.response) {
|
|
||||||
const buffer = imagesRequest.response as ArrayBuffer;
|
|
||||||
untar(buffer).then(files => {
|
|
||||||
for (const tarFile of files) {
|
|
||||||
const card = cardDatabase.cards!.find(c => tarFile.name == `${c.artFileName}.webp`);
|
|
||||||
if (!card) continue;
|
|
||||||
card.imageUrl = tarFile.getBlobUrl();
|
|
||||||
}
|
|
||||||
resolve(cards);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Error downloading card images: response was ${imagesRequest.status}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
imagesRequest.addEventListener('error', () => {
|
|
||||||
reject(new Error('Error downloading card images: no further information.'))
|
|
||||||
});
|
|
||||||
imagesRequest.send();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
|
|
||||||
}
|
}
|
||||||
});
|
const cardListRequest = new XMLHttpRequest();
|
||||||
cardListRequest.addEventListener('error', e => {
|
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
|
||||||
reject(new Error('Error downloading card database: no further information.'))
|
cardListRequest.addEventListener('load', e => {
|
||||||
});
|
const cards: Card[] = [ ];
|
||||||
cardListRequest.send();
|
if (cardListRequest.status == 200) {
|
||||||
|
const s = cardListRequest.responseText;
|
||||||
|
const response = JSON.parse(s) as object[];
|
||||||
|
for (const o of response) {
|
||||||
|
const card = Card.fromJson(o);
|
||||||
|
cards.push(card);
|
||||||
|
cardDatabase.lastOfficialCardNumber = Math.max(cardDatabase.lastOfficialCardNumber, card.number);
|
||||||
|
if (card.number < 0) cardDatabase._byAltNumber[-card.number] = card;
|
||||||
|
else if (card.altNumber != null && card.altNumber < 0) cardDatabase._byAltNumber[-card.altNumber] = card;
|
||||||
|
}
|
||||||
|
cardDatabase.cards = cards;
|
||||||
|
|
||||||
|
if (window.location.protocol == 'file:') {
|
||||||
|
// If debugging locally, just read the files from the assets directory.
|
||||||
|
for (const card of cardDatabase.cards!) {
|
||||||
|
card.imageUrl = `assets/external/card/${card.artFileName}.webp`
|
||||||
|
}
|
||||||
|
resolve(cards);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise, download and extract card images from a .tar package.
|
||||||
|
const imagesRequest = new XMLHttpRequest();
|
||||||
|
imagesRequest.responseType = 'arraybuffer';
|
||||||
|
imagesRequest.open('GET', 'assets/external/card.tar');
|
||||||
|
imagesRequest.addEventListener('load', () => {
|
||||||
|
if (imagesRequest.status == 200 && imagesRequest.response) {
|
||||||
|
const buffer = imagesRequest.response as ArrayBuffer;
|
||||||
|
untar(buffer).then(files => {
|
||||||
|
for (const tarFile of files) {
|
||||||
|
const card = cardDatabase.cards!.find(c => tarFile.name == `${c.artFileName}.webp`);
|
||||||
|
if (!card) continue;
|
||||||
|
card.imageUrl = tarFile.getBlobUrl();
|
||||||
|
}
|
||||||
|
resolve(cards);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Error downloading card images: response was ${imagesRequest.status}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
imagesRequest.addEventListener('error', () => {
|
||||||
|
reject(new Error('Error downloading card images: no further information.'))
|
||||||
|
});
|
||||||
|
imagesRequest.send();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cardListRequest.addEventListener('error', e => {
|
||||||
|
reject(new Error('Error downloading card database: no further information.'))
|
||||||
|
});
|
||||||
|
cardListRequest.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload the font first; calculating text scale depends on this.
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'preload';
|
||||||
|
link.href = 'assets/external/splatoon1.woff2';
|
||||||
|
link.as = 'font';
|
||||||
|
link.type = 'font/woff';
|
||||||
|
link.crossOrigin = '';
|
||||||
|
link.addEventListener('load', afterFontLoaded);
|
||||||
|
link.addEventListener('error', afterFontLoaded);
|
||||||
|
document.head.appendChild(link);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,73 @@
|
||||||
class CardDisplay {
|
class CardDisplay implements ICardElement {
|
||||||
readonly card: Card;
|
readonly card: Card;
|
||||||
readonly element: SVGSVGElement;
|
level: number;
|
||||||
|
readonly element: HTMLElement;
|
||||||
|
readonly svg: SVGSVGElement;
|
||||||
|
private readonly sizeElement: SVGTextElement;
|
||||||
|
private readonly specialCostGroup: SVGGElement;
|
||||||
|
private idNumber: number;
|
||||||
|
|
||||||
constructor(card: Card, level: number) {
|
private static nextIdNumber = 0;
|
||||||
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
||||||
svg.setAttribute('viewBox', '0 0 635 885');
|
private static getGradientId(baseId: string, scale: number) {
|
||||||
|
if (scale >= 20) return baseId;
|
||||||
|
|
||||||
|
const roundedScale = Math.round(scale * 20);
|
||||||
|
if (roundedScale >= 20) return baseId;
|
||||||
|
|
||||||
|
const id = `${baseId}${roundedScale}`;
|
||||||
|
if (document.getElementById(id)) return id;
|
||||||
|
|
||||||
|
const baseElement = document.getElementById(baseId);
|
||||||
|
if (!baseElement)
|
||||||
|
throw new Error(`Base gradient element '${baseId}' not found.`);
|
||||||
|
const el = <Element> baseElement!.cloneNode(true);
|
||||||
|
baseElement.insertAdjacentElement('afterend', el);
|
||||||
|
el.setAttribute('id', id);
|
||||||
|
el.setAttribute('gradientTransform', `translate(${-6350 / roundedScale + 317.5} 0) scale(${20 / roundedScale} 1)`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(card: Card, level: number, elementType: string = 'div') {
|
||||||
|
this.idNumber = CardDisplay.nextIdNumber++;
|
||||||
|
this.card = card;
|
||||||
|
this.level = level;
|
||||||
|
|
||||||
|
const element = document.createElement(elementType);
|
||||||
|
element.classList.add('card');
|
||||||
|
element.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
|
||||||
|
this.element = element;
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 442 616');
|
||||||
svg.setAttribute('alt', card.name);
|
svg.setAttribute('alt', card.name);
|
||||||
this.element = svg;
|
this.svg = svg;
|
||||||
|
element.appendChild(svg);
|
||||||
|
|
||||||
svg.classList.add('card');
|
if (card.isCustom) svg.classList.add('custom');
|
||||||
svg.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
|
else if (card.isUpcoming) svg.classList.add('upcoming');
|
||||||
if (card.number < 0) svg.classList.add('upcoming');
|
|
||||||
svg.dataset.cardNumber = card.number.toString();
|
svg.dataset.cardNumber = card.number.toString();
|
||||||
svg.style.setProperty("--number", card.number.toString());
|
svg.style.setProperty("--number", card.number.toString());
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
image.setAttribute('href', `assets/CardBackground-${card.rarity}-${level > 0 ? '1' : '0'}.webp`);
|
image.setAttribute('class', 'cardDisplayBackground');
|
||||||
|
image.setAttribute('href', `assets/external/CardBackground${card.isCustom ? '-custom' : ''}-${card.rarity}-${level > 0 ? '1' : '0'}.webp`);
|
||||||
image.setAttribute('width', '100%');
|
image.setAttribute('width', '100%');
|
||||||
image.setAttribute('height', '100%');
|
image.setAttribute('height', '100%');
|
||||||
svg.appendChild(image);
|
svg.appendChild(image);
|
||||||
|
|
||||||
if (level == 0) {
|
if (level > 0) {
|
||||||
svg.insertAdjacentHTML('beforeend', `<image href="assets/external/CardInk.webp" width="635" height="885" clip-path="url(#myClip)"/>`);
|
const r1 = card.inkColour1.r / 255;
|
||||||
} else {
|
const g1 = card.inkColour1.g / 255;
|
||||||
|
const b1 = card.inkColour1.b / 255;
|
||||||
|
const dr = (card.inkColour2.r - card.inkColour1.r) / 255;
|
||||||
|
const dg = (card.inkColour2.g - card.inkColour1.g) / 255;
|
||||||
|
const db = (card.inkColour2.b - card.inkColour1.b) / 255;
|
||||||
svg.insertAdjacentHTML('beforeend', `
|
svg.insertAdjacentHTML('beforeend', `
|
||||||
<filter id="ink1-${card.number}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${card.inkColour1.r / 255} 0 0 0 0 0 ${card.inkColour1.g / 255} 0 0 0 0 0 ${card.inkColour1.b / 255} 0 0 0 0 0 0.88 0"/></filter>
|
<filter id="ink-${this.idNumber}" class="inkFilter" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${dr} 0 0 0 ${r1} 0 ${dg} 0 0 ${g1} 0 0 ${db} 0 ${b1} 0 0 0 0.88 0"/></filter>
|
||||||
<image href="assets/external/CardInk-1.webp" width="635" height="885" clip-path="url(#myClip)" filter="url(#ink1-${card.number})"/>
|
<image href="assets/external/CardInk_00.webp" width="100%" height="100%" clip-path="url(#cardBorder)" filter="url(#ink-${this.idNumber})"/>
|
||||||
<filter id="ink2-${card.number}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${card.inkColour2.r / 255} 0 0 0 0 0 ${card.inkColour2.g / 255} 0 0 0 0 0 ${card.inkColour2.b / 255} 0 0 0 0 0 0.88 0"/></filter>
|
<image href="assets/external/CardFrame_01.webp" width="100%" height="100%" clip-path="url(#cardBorder)"/>
|
||||||
<image href="assets/external/CardInk-2.webp" width="635" height="885" clip-path="url(#myClip)" filter="url(#ink2-${card.number})"/>
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +83,8 @@ class CardDisplay {
|
||||||
|
|
||||||
// Grid
|
// Grid
|
||||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
g.setAttribute('transform', 'translate(380 604) rotate(6.5) scale(0.283)');
|
g.setAttribute('class', 'cardGrid');
|
||||||
|
g.setAttribute('transform', 'translate(264 420) rotate(6.5) scale(0.197)');
|
||||||
svg.appendChild(g);
|
svg.appendChild(g);
|
||||||
|
|
||||||
CardDisplay.CreateSvgCardGrid(card, g);
|
CardDisplay.CreateSvgCardGrid(card, g);
|
||||||
|
|
@ -53,11 +93,12 @@ class CardDisplay {
|
||||||
const text1 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
const text1 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
text1.setAttribute('class', 'cardDisplayName');
|
text1.setAttribute('class', 'cardDisplayName');
|
||||||
text1.setAttribute('x', '50%');
|
text1.setAttribute('x', '50%');
|
||||||
text1.setAttribute('y', '168');
|
text1.setAttribute('y', '19%');
|
||||||
text1.setAttribute('font-size', '76');
|
text1.setAttribute('text-anchor', 'middle');
|
||||||
|
text1.setAttribute('font-size', '53');
|
||||||
text1.setAttribute('font-weight', 'bold');
|
text1.setAttribute('font-weight', 'bold');
|
||||||
text1.setAttribute('stroke', 'black');
|
text1.setAttribute('stroke', 'black');
|
||||||
text1.setAttribute('stroke-width', '15');
|
text1.setAttribute('stroke-width', '10.5');
|
||||||
text1.setAttribute('stroke-linejoin', 'round');
|
text1.setAttribute('stroke-linejoin', 'round');
|
||||||
text1.setAttribute('paint-order', 'stroke');
|
text1.setAttribute('paint-order', 'stroke');
|
||||||
text1.setAttribute('word-spacing', '-10');
|
text1.setAttribute('word-spacing', '-10');
|
||||||
|
|
@ -68,37 +109,30 @@ class CardDisplay {
|
||||||
text1.setAttribute('fill', '#6038FF');
|
text1.setAttribute('fill', '#6038FF');
|
||||||
break;
|
break;
|
||||||
case Rarity.Rare:
|
case Rarity.Rare:
|
||||||
svg.insertAdjacentHTML('beforeend', `
|
text1.setAttribute('fill', `url("#${CardDisplay.getGradientId('rareGradient', card.textScale)}")`);
|
||||||
<linearGradient id='rareGradient' y1='25%' spreadMethod='reflect'>
|
|
||||||
<stop offset='0%' stop-color='#FEF9C6'/>
|
|
||||||
<stop offset='50%' stop-color='#DFAF17'/>
|
|
||||||
<stop offset='100%' stop-color='#FEF9C6'/>
|
|
||||||
</linearGradient>
|
|
||||||
`);
|
|
||||||
text1.setAttribute('fill', 'url("#rareGradient")');
|
|
||||||
break;
|
break;
|
||||||
case Rarity.Fresh:
|
case Rarity.Fresh:
|
||||||
svg.insertAdjacentHTML('beforeend', `
|
text1.setAttribute('fill', `url("#${CardDisplay.getGradientId('freshGradient', card.textScale)}")`);
|
||||||
<linearGradient id='freshGradient' y2='25%'>
|
|
||||||
<stop offset='0%' stop-color='#FF8EDD'/>
|
|
||||||
<stop offset='25%' stop-color='#FFEC9F'/>
|
|
||||||
<stop offset='50%' stop-color='#B84386'/>
|
|
||||||
<stop offset='75%' stop-color='#2BEFC8'/>
|
|
||||||
<stop offset='100%' stop-color='#FF8EDD'/>
|
|
||||||
</linearGradient>
|
|
||||||
`);
|
|
||||||
text1.setAttribute('fill', 'url("#freshGradient")');
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (card.line1 && card.line2) {
|
if (card.line1 != null && card.line2 != null) {
|
||||||
const tspan1 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
const tspan1 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
||||||
tspan1.setAttribute('y', '122');
|
tspan1.setAttribute('y', '13.8%');
|
||||||
tspan1.appendChild(document.createTextNode(card.line1));
|
tspan1.appendChild(document.createTextNode(card.line1));
|
||||||
text1.appendChild(tspan1);
|
text1.appendChild(tspan1);
|
||||||
|
|
||||||
|
if (!card.line1.endsWith('-') && !card.line2.startsWith('-')) {
|
||||||
|
// Add a space in the middle, to be included when copying the card name.
|
||||||
|
const tspanBr = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
||||||
|
tspanBr.setAttribute('x', '50%');
|
||||||
|
tspanBr.setAttribute('y', '19%');
|
||||||
|
tspanBr.appendChild(document.createTextNode(' '));
|
||||||
|
text1.appendChild(tspanBr);
|
||||||
|
}
|
||||||
|
|
||||||
const tspan2 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
const tspan2 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
||||||
tspan2.setAttribute('x', '50%');
|
tspan2.setAttribute('x', '50%');
|
||||||
tspan2.setAttribute('y', '216');
|
tspan2.setAttribute('y', '24.4%');
|
||||||
tspan2.appendChild(document.createTextNode(card.line2));
|
tspan2.appendChild(document.createTextNode(card.line2));
|
||||||
text1.appendChild(tspan2);
|
text1.appendChild(tspan2);
|
||||||
} else
|
} else
|
||||||
|
|
@ -107,29 +141,18 @@ class CardDisplay {
|
||||||
svg.appendChild(text1);
|
svg.appendChild(text1);
|
||||||
|
|
||||||
// Size
|
// Size
|
||||||
svg.insertAdjacentHTML('beforeend', `<image href='assets/external/Game Assets/CardCost_0${card.rarity}.png' width='80' height='80' transform='translate(12 798) rotate(-45) scale(1.33)'/>`);
|
svg.insertAdjacentHTML('beforeend', `<image class='cardSizeBackground' href='assets/external/CardCost_0${card.rarity}.webp' width='74.1' height='74.1' transform='translate(8.4 555) rotate(-45)'/>`);
|
||||||
svg.insertAdjacentHTML('beforeend', `<text fill='white' stroke='${card.rarity == Rarity.Common ? '#482BB4' : card.rarity == Rarity.Rare ? '#8B7E25' : '#481EF9'}' paint-order='stroke' stroke-width='5' font-size='48' y='816' x='87'>${card.size}</text>`);
|
svg.insertAdjacentHTML('beforeend', `<text fill='white' stroke='${card.rarity == Rarity.Common ? '#482BB4' : card.rarity == Rarity.Rare ? '#8B7E25' : '#481EF9'}' paint-order='stroke' stroke-width='5' font-size='33.4' x='13.7%' y='92.2%' text-anchor='middle'>${card.size}</text>`);
|
||||||
|
this.sizeElement = svg.lastElementChild as SVGTextElement;
|
||||||
|
|
||||||
// Special cost
|
// Special cost
|
||||||
const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
this.specialCostGroup = g2;
|
||||||
g2.setAttribute('class', 'specialCost');
|
g2.setAttribute('class', 'specialCost');
|
||||||
g2.setAttribute('transform', 'translate(170 806) scale(0.32)');
|
g2.setAttribute('transform', 'translate(118 561) scale(0.222)');
|
||||||
svg.appendChild(g2);
|
svg.appendChild(g2);
|
||||||
|
|
||||||
for (let i = 0; i < card.specialCost; i++) {
|
this.setSpecialCost(card.specialCost);
|
||||||
let rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
||||||
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
|
||||||
image.setAttribute('href', 'assets/SpecialOverlay.png');
|
|
||||||
for (const el of [ rect, image ]) {
|
|
||||||
el.setAttribute('x', (110 * (i % 5)).toString());
|
|
||||||
el.setAttribute('y', (-125 * Math.floor(i / 5)).toString());
|
|
||||||
el.setAttribute('width', '95');
|
|
||||||
el.setAttribute('height', '95');
|
|
||||||
g2.appendChild(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.card = card;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static CreateSvgCardGrid(card: Card, parent: SVGElement) {
|
static CreateSvgCardGrid(card: Card, parent: SVGElement) {
|
||||||
|
|
@ -148,7 +171,7 @@ class CardDisplay {
|
||||||
rect.classList.add(card.grid[x][y] == Space.SpecialInactive1 ? 'special' : 'ink');
|
rect.classList.add(card.grid[x][y] == Space.SpecialInactive1 ? 'special' : 'ink');
|
||||||
const elements: Element[] = [rect];
|
const elements: Element[] = [rect];
|
||||||
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
image.setAttribute('href', card.grid[x][y] == Space.SpecialInactive1 ? 'assets/SpecialOverlay.png' : 'assets/InkOverlay.png');
|
image.setAttribute('href', card.grid[x][y] == Space.SpecialInactive1 ? 'assets/SpecialOverlay.webp' : 'assets/InkOverlay.webp');
|
||||||
elements.push(image);
|
elements.push(image);
|
||||||
|
|
||||||
for (const el of elements) {
|
for (const el of elements) {
|
||||||
|
|
@ -162,4 +185,24 @@ class CardDisplay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSpecialCost(value: number) {
|
||||||
|
clearChildren(this.specialCostGroup);
|
||||||
|
for (let i = 0; i < value; i++) {
|
||||||
|
let rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
|
image.setAttribute('href', 'assets/SpecialOverlay.webp');
|
||||||
|
for (const el of [ rect, image ]) {
|
||||||
|
el.setAttribute('x', (110 * (i % 5)).toString());
|
||||||
|
el.setAttribute('y', (-125 * Math.floor(i / 5)).toString());
|
||||||
|
el.setAttribute('width', '95');
|
||||||
|
el.setAttribute('height', '95');
|
||||||
|
this.specialCostGroup.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSize(value: number) {
|
||||||
|
this.sizeElement.innerHTML = value.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,37 @@
|
||||||
class CardList {
|
class CardList<T extends ICardElement> {
|
||||||
readonly listElement: HTMLElement;
|
readonly listElement: HTMLElement;
|
||||||
readonly sortBox: HTMLSelectElement;
|
readonly sortBox: HTMLSelectElement;
|
||||||
readonly filterBox: HTMLInputElement;
|
readonly filterBox: HTMLInputElement;
|
||||||
readonly cardButtons: CardButton[] = [ ];
|
readonly cardButtons: T[] = [ ];
|
||||||
|
|
||||||
static readonly cardSortOrders: { [key: string]: (a: Card, b: Card) => number } = {
|
static readonly cardSortOrders: { [key: string]: (a: Card, b: Card) => number } = {
|
||||||
'number': (a, b) => compareCardNumbers(a.number, b.number),
|
'number': (a, b) => CardList.compareByNumber(a, b),
|
||||||
'name': (a, b) => a.name.localeCompare(b.name),
|
'name': (a, b) => CardList.compareByName(a, b),
|
||||||
'size': (a, b) => a.size != b.size ? a.size - b.size : compareCardNumbers(a.number, b.number),
|
'size': (a, b) => CardList.compareBySize(a, b),
|
||||||
'rarity': (a, b) => a.rarity != b.rarity ? b.rarity - a.rarity : compareCardNumbers(a.number, b.number),
|
'rarity': (a, b) => CardList.compareByRarity(a, b),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static compareCardNumbers(a: number, b: number) {
|
||||||
|
// Sort upcoming cards after released cards.
|
||||||
|
return a >= 0 ? (b >= 0 ? a - b : -1) : (b >= 0 ? 1 : b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
static compareByInGameSecondaryOrder(a: Card, b: Card) {
|
||||||
|
// Keep variants and special weapons together.
|
||||||
|
// TODO: There may be a better way to do this than hard-coding the first special weapon card number.
|
||||||
|
const baseA = a.isVariantOf ?? (a.isSpecialWeapon ? 70 : a.number);
|
||||||
|
const baseB = b.isVariantOf ?? (b.isSpecialWeapon ? 70 : b.number);
|
||||||
|
if (baseA != baseB) return CardList.compareCardNumbers(baseA, baseB);
|
||||||
|
|
||||||
|
// Sort by card number within each category.
|
||||||
|
return CardList.compareCardNumbers(a.number, b.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
static compareByNumber(a: Card, b: Card) { return CardList.compareCardNumbers(a.number, b.number); }
|
||||||
|
static compareByName(a: Card, b: Card) { return a.name.localeCompare(b.name); }
|
||||||
|
static compareBySize(a: Card, b: Card) { return a.size != b.size ? a.size - b.size : CardList.compareByInGameSecondaryOrder(a, b); }
|
||||||
|
static compareByRarity(a: Card, b: Card) { return a.rarity != b.rarity ? b.rarity - a.rarity : CardList.compareByInGameSecondaryOrder(a, b); }
|
||||||
|
|
||||||
constructor(listElement: HTMLElement, sortBox: HTMLSelectElement, filterBox: HTMLInputElement) {
|
constructor(listElement: HTMLElement, sortBox: HTMLSelectElement, filterBox: HTMLInputElement) {
|
||||||
this.listElement = listElement;
|
this.listElement = listElement;
|
||||||
this.sortBox = sortBox;
|
this.sortBox = sortBox;
|
||||||
|
|
@ -21,7 +42,7 @@ class CardList {
|
||||||
filterBox.addEventListener('input', () => {
|
filterBox.addEventListener('input', () => {
|
||||||
const s = filterBox.value.toLowerCase();
|
const s = filterBox.value.toLowerCase();
|
||||||
for (const button of this.cardButtons)
|
for (const button of this.cardButtons)
|
||||||
button.buttonElement.hidden = s != '' && !button.card.name.toLowerCase().includes(s);
|
button.element.hidden = s != '' && !button.card.name.toLowerCase().includes(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const label in CardList.cardSortOrders) {
|
for (const label in CardList.cardSortOrders) {
|
||||||
|
|
@ -38,17 +59,42 @@ class CardList {
|
||||||
clearChildren(this.listElement);
|
clearChildren(this.listElement);
|
||||||
this.cardButtons.sort((a, b) => sortOrder(a.card, b.card));
|
this.cardButtons.sort((a, b) => sortOrder(a.card, b.card));
|
||||||
for (const button of this.cardButtons)
|
for (const button of this.cardButtons)
|
||||||
this.listElement.appendChild(button.buttonElement);
|
this.listElement.appendChild(button.element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromId(id: string, sortBoxId: string, filterBoxId: string) {
|
static fromId<T extends ICardElement>(id: string, sortBoxId: string, filterBoxId: string) {
|
||||||
return new CardList(document.getElementById(id)!, document.getElementById(sortBoxId) as HTMLSelectElement, document.getElementById(filterBoxId) as HTMLInputElement);
|
return new CardList<T>(document.getElementById(id)!, document.getElementById(sortBoxId) as HTMLSelectElement, document.getElementById(filterBoxId) as HTMLInputElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(button: CardButton) {
|
add(button: T) {
|
||||||
this.cardButtons.push(button);
|
this.cardButtons.push(button);
|
||||||
this.listElement.appendChild(button.buttonElement);
|
this.listElement.appendChild(button.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(button: T, card: Card) {
|
||||||
|
const i = this.cardButtons.findIndex(c => c.card.number == card.number);
|
||||||
|
if (i < 0) throw new Error('The card to update was not found in the list.');
|
||||||
|
const existingButton = this.cardButtons[i];
|
||||||
|
this.cardButtons.splice(i, 1, button);
|
||||||
|
this.listElement.replaceChild(button.element, existingButton.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(card: Card) {
|
||||||
|
const i = this.cardButtons.findIndex(b => b.card.number == card.number);
|
||||||
|
if (i < 0) return;
|
||||||
|
this.listElement.removeChild(this.cardButtons[i].element);
|
||||||
|
this.cardButtons.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllCustomCards() {
|
||||||
|
for (let i = this.cardButtons.length - 1; i >= 0; i--) {
|
||||||
|
const button = this.cardButtons[i];
|
||||||
|
if (button.card.isCustom) {
|
||||||
|
this.listElement.removeChild(button.element);
|
||||||
|
this.cardButtons.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSortOrder(sortOrder: string) {
|
setSortOrder(sortOrder: string) {
|
||||||
|
|
@ -59,11 +105,6 @@ class CardList {
|
||||||
clearFilter() {
|
clearFilter() {
|
||||||
this.filterBox.value = '';
|
this.filterBox.value = '';
|
||||||
for (const button of this.cardButtons)
|
for (const button of this.cardButtons)
|
||||||
button.buttonElement.hidden = false;
|
button.element.hidden = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareCardNumbers(a: number, b: number) {
|
|
||||||
// Sort upcoming cards after released cards.
|
|
||||||
return a >= 0 ? (b >= 0 ? a - b : -1) : (b >= 0 ? 1 : b - a);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ class CheckButtonGroup<TValue> {
|
||||||
entries: Array<{ button: CheckButton, value: TValue }> = [ ];
|
entries: Array<{ button: CheckButton, value: TValue }> = [ ];
|
||||||
parentElement: HTMLElement | null;
|
parentElement: HTMLElement | null;
|
||||||
value: TValue | null = null;
|
value: TValue | null = null;
|
||||||
|
allowMultipleSelections = false;
|
||||||
|
|
||||||
constructor(parentElement?: HTMLElement | null) {
|
constructor(parentElement?: HTMLElement | null) {
|
||||||
this.parentElement = parentElement ?? null;
|
this.parentElement = parentElement ?? null;
|
||||||
|
|
@ -12,7 +13,10 @@ class CheckButtonGroup<TValue> {
|
||||||
|
|
||||||
private setupButton(button: CheckButton, value: TValue) {
|
private setupButton(button: CheckButton, value: TValue) {
|
||||||
button.buttonElement.addEventListener('click', () => {
|
button.buttonElement.addEventListener('click', () => {
|
||||||
if (button.enabled && !button.checked) {
|
if (!button.enabled) return;
|
||||||
|
if (this.allowMultipleSelections)
|
||||||
|
button.checked = !button.checked;
|
||||||
|
else if (!button.checked) {
|
||||||
for (const el of this.entries) {
|
for (const el of this.entries) {
|
||||||
if (el.button == button) {
|
if (el.button == button) {
|
||||||
el.button.checked = true;
|
el.button.checked = true;
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,34 @@ interface AppConfig {
|
||||||
discordTitle?: string
|
discordTitle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SpecialWeaponSorting {
|
||||||
|
First,
|
||||||
|
Last,
|
||||||
|
InOrder
|
||||||
|
}
|
||||||
|
|
||||||
class Config {
|
class Config {
|
||||||
name: string | null = null;
|
name: string | null = null;
|
||||||
colourLock = true;
|
colourLock = true;
|
||||||
|
goodColour?: string;
|
||||||
|
badColour?: string;
|
||||||
absoluteTurnNumber = false;
|
absoluteTurnNumber = false;
|
||||||
|
specialWeaponSorting = SpecialWeaponSorting.First;
|
||||||
|
lastCustomRoomConfig?: CustomRoomConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomRoomConfig {
|
||||||
|
maxPlayers: number;
|
||||||
|
turnTimeLimit: number | null;
|
||||||
|
goalWinCount: number | null;
|
||||||
|
allowUpcomingCards: boolean;
|
||||||
|
allowCustomCards: boolean;
|
||||||
|
stageSelectionMethodFirst: StageSelectionMethod;
|
||||||
|
stageSelectionMethodAfterWin: StageSelectionMethod | null;
|
||||||
|
stageSelectionMethodAfterDraw: StageSelectionMethod | null;
|
||||||
|
forceSameDecksAfterDraw: boolean;
|
||||||
|
stageSwitch: number[];
|
||||||
|
spectate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare var config: AppConfig;
|
declare var config: AppConfig;
|
||||||
|
|
@ -29,3 +53,11 @@ let userConfig = new Config();
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
localStorage.setItem('settings', JSON.stringify(userConfig));
|
localStorage.setItem('settings', JSON.stringify(userConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveChecklist() {
|
||||||
|
localStorage.setItem('checklist', JSON.stringify(ownedCards));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCustomCards() {
|
||||||
|
localStorage.setItem('customCards', JSON.stringify(cardDatabase.customCards));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class SavedDeck {
|
||||||
this.isReadOnly = isReadOnly;
|
this.isReadOnly = isReadOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fromJson(obj: any) {
|
||||||
|
return new SavedDeck(obj.name, obj.sleeves ?? 0, obj.cards, obj.upgrades ?? new Array(15).fill(1), false);
|
||||||
|
}
|
||||||
|
|
||||||
get isValid() {
|
get isValid() {
|
||||||
if (!cardDatabase.cards) throw new Error('Card database must be loaded to validate decks.');
|
if (!cardDatabase.cards) throw new Error('Card database must be loaded to validate decks.');
|
||||||
if (this.cards.length != 15) return false;
|
if (this.cards.length != 15) return false;
|
||||||
|
|
@ -24,6 +28,11 @@ class SavedDeck {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeckFullExport {
|
||||||
|
decks: SavedDeck[] | number[][];
|
||||||
|
customCards?: {[key: number]: Card};
|
||||||
|
}
|
||||||
|
|
||||||
class Deck {
|
class Deck {
|
||||||
name: string;
|
name: string;
|
||||||
sleeves: number;
|
sleeves: number;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ interface Game {
|
||||||
turnTimeLeft: number | null,
|
turnTimeLeft: number | null,
|
||||||
/** The number of game wins needed to win the set, or null if no goal win count is set. */
|
/** The number of game wins needed to win the set, or null if no goal win count is set. */
|
||||||
goalWinCount: number | null,
|
goalWinCount: number | null,
|
||||||
|
/** Whether upcoming cards may be used. */
|
||||||
|
allowUpcomingCards: boolean,
|
||||||
|
/** Whether custom cards may be used. */
|
||||||
|
allowCustomCards: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A UUID used to identify the client. */
|
/** A UUID used to identify the client. */
|
||||||
|
|
@ -22,8 +26,10 @@ let currentGame: {
|
||||||
game: Game,
|
game: Game,
|
||||||
/** The user's player data, or null if they are spectating. */
|
/** The user's player data, or null if they are spectating. */
|
||||||
me: PlayerData | null,
|
me: PlayerData | null,
|
||||||
|
isHost: boolean,
|
||||||
/** The WebSocket used for receiving game events, or null if not yet connected. */
|
/** The WebSocket used for receiving game events, or null if not yet connected. */
|
||||||
webSocket: WebSocket | null
|
webSocket: WebSocket | null,
|
||||||
|
reconnecting?: boolean
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
let enterGameTimeout: number | null = null;
|
let enterGameTimeout: number | null = null;
|
||||||
|
|
@ -45,8 +51,5 @@ let currentReplay: {
|
||||||
watchingPlayer: number
|
watchingPlayer: number
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
const playerList = document.getElementById('playerList')!;
|
|
||||||
const playerListItems: HTMLElement[] = [ ];
|
|
||||||
|
|
||||||
const canPlayCard = [ false, false, false, false ];
|
const canPlayCard = [ false, false, false, false ];
|
||||||
const canPlayCardAsSpecialAttack = [ false, false, false, false ];
|
const canPlayCardAsSpecialAttack = [ false, false, false, false ];
|
||||||
|
|
|
||||||
4
TableturfBattleClient/src/ICardElement.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
interface ICardElement {
|
||||||
|
card: Card;
|
||||||
|
element: HTMLElement;
|
||||||
|
}
|
||||||
|
|
@ -3,13 +3,17 @@
|
||||||
const deckNameLabel2 = document.getElementById('deckName2')!;
|
const deckNameLabel2 = document.getElementById('deckName2')!;
|
||||||
const deckEditSize = document.getElementById('deckEditSize')!;
|
const deckEditSize = document.getElementById('deckEditSize')!;
|
||||||
const deckCardListEdit = document.getElementById('deckCardListEdit')!;
|
const deckCardListEdit = document.getElementById('deckCardListEdit')!;
|
||||||
const cardList = CardList.fromId('cardList', 'cardListSortBox', 'cardListFilterBox');
|
const cardList = CardList.fromId<CardButton>('cardList', 'cardListSortBox', 'cardListFilterBox');
|
||||||
const cardListButtonGroup = new CheckButtonGroup<Card>();
|
const cardListButtonGroup = new CheckButtonGroup<Card>();
|
||||||
|
|
||||||
|
const deckEditMenu = document.getElementById('deckEditMenu')!;
|
||||||
|
const deckEditMenuButton = document.getElementById('deckEditMenuButton') as HTMLButtonElement;
|
||||||
const deckSortButton = document.getElementById('deckSortButton') as HTMLButtonElement;
|
const deckSortButton = document.getElementById('deckSortButton') as HTMLButtonElement;
|
||||||
const deckTestButton = document.getElementById('deckTestButton') as HTMLButtonElement;
|
const deckTestButton = document.getElementById('deckTestButton') as HTMLButtonElement;
|
||||||
const deckSaveButton = document.getElementById('deckSaveButton') as HTMLButtonElement;
|
const deckSaveButton = document.getElementById('deckSaveButton') as HTMLButtonElement;
|
||||||
const deckCancelButton = document.getElementById('deckCancelButton') as HTMLButtonElement;
|
const deckCancelButton = document.getElementById('deckCancelButton') as HTMLButtonElement;
|
||||||
const deckCardListBackButton = document.getElementById('deckCardListBackButton') as HTMLLinkElement;
|
const deckCardListBackButton = document.getElementById('deckCardListBackButton') as HTMLLinkElement;
|
||||||
|
const deckEditorRemoveButton = document.getElementById('deckEditorRemoveButton') as HTMLButtonElement;
|
||||||
const cardListFilterBox = document.getElementById('cardListFilterBox') as HTMLSelectElement;
|
const cardListFilterBox = document.getElementById('cardListFilterBox') as HTMLSelectElement;
|
||||||
const testStageSelectionList = document.getElementById('testStageSelectionList')!;
|
const testStageSelectionList = document.getElementById('testStageSelectionList')!;
|
||||||
const testStageButtons = new CheckButtonGroup<Stage>(testStageSelectionList);
|
const testStageButtons = new CheckButtonGroup<Stage>(testStageSelectionList);
|
||||||
|
|
@ -21,45 +25,71 @@ let draggingCardButton: Element | null = null;
|
||||||
|
|
||||||
function deckEditInitCardDatabase(cards: Card[]) {
|
function deckEditInitCardDatabase(cards: Card[]) {
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const button = new CardButton(card);
|
addCardToDeckEditor(card);
|
||||||
cardList.add(button);
|
|
||||||
cardListButtonGroup.add(button, card);
|
|
||||||
button.buttonElement.addEventListener('click', () => {
|
|
||||||
if (!button.enabled) return;
|
|
||||||
|
|
||||||
for (const button2 of cardList.cardButtons) {
|
|
||||||
if (button2 != button)
|
|
||||||
button2.checked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = deckEditCardButtons.entries.findIndex(el => el.button.checked);
|
|
||||||
if (index < 0) return;
|
|
||||||
const oldEntry = deckEditCardButtons.entries[index];
|
|
||||||
const oldCardNumber = oldEntry.value;
|
|
||||||
|
|
||||||
if (oldCardNumber != 0)
|
|
||||||
cardListButtonGroup.entries.find(e => e.value.number == oldCardNumber)!.button.enabled = true;
|
|
||||||
cardListButtonGroup.entries.find(e => e.value.number == card.number)!.button.enabled = false;
|
|
||||||
|
|
||||||
const button3 = createDeckEditCardButton(card.number);
|
|
||||||
button3.checked = true;
|
|
||||||
|
|
||||||
deckEditCardButtons.replace(index, button3, card.number);
|
|
||||||
deckEditUpdateSize();
|
|
||||||
|
|
||||||
cardList.listElement.parentElement!.classList.remove('selecting');
|
|
||||||
if (!deckModified) {
|
|
||||||
deckModified = true;
|
|
||||||
window.addEventListener('beforeunload', onBeforeUnload_deckEditor);
|
|
||||||
}
|
|
||||||
selectFirstEmptySlot();
|
|
||||||
});
|
|
||||||
addTestCard(card);
|
addTestCard(card);
|
||||||
}
|
}
|
||||||
cardList.setSortOrder('size');
|
cardList.setSortOrder('size');
|
||||||
testAllCardsList.setSortOrder('size');
|
testAllCardsList.setSortOrder('size');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addCardToDeckEditor(card: Card) {
|
||||||
|
const button = new CardButton(card);
|
||||||
|
cardList.add(button);
|
||||||
|
cardListButtonGroup.add(button, card);
|
||||||
|
button.buttonElement.addEventListener('click', () => {
|
||||||
|
if (!button.enabled) return;
|
||||||
|
|
||||||
|
for (const button2 of cardList.cardButtons) {
|
||||||
|
if (button2 != button)
|
||||||
|
button2.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = deckEditCardButtons.entries.findIndex(el => el.button.checked);
|
||||||
|
if (index < 0) return;
|
||||||
|
const oldEntry = deckEditCardButtons.entries[index];
|
||||||
|
const oldCardNumber = oldEntry.value;
|
||||||
|
|
||||||
|
if (oldCardNumber != 0)
|
||||||
|
cardListButtonGroup.entries.find(e => e.value.number == oldCardNumber || e.value.altNumber == oldCardNumber)!.button.enabled = true;
|
||||||
|
cardListButtonGroup.entries.find(e => e.value.number == card.number)!.button.enabled = false;
|
||||||
|
|
||||||
|
const button3 = createDeckEditCardButton(card.number);
|
||||||
|
button3.checked = true;
|
||||||
|
|
||||||
|
deckEditCardButtons.replace(index, button3, card.number);
|
||||||
|
deckEditUpdateSize();
|
||||||
|
|
||||||
|
cardList.listElement.parentElement!.classList.remove('selecting');
|
||||||
|
if (!deckModified) {
|
||||||
|
deckModified = true;
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload_deckEditor);
|
||||||
|
}
|
||||||
|
selectFirstEmptySlot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deckEditorRemoveButton.addEventListener('click', () => {
|
||||||
|
const index = deckEditCardButtons.entries.findIndex(el => el.button.checked);
|
||||||
|
if (index < 0) return;
|
||||||
|
const oldEntry = deckEditCardButtons.entries[index];
|
||||||
|
const oldCardNumber = oldEntry.value;
|
||||||
|
|
||||||
|
if (oldCardNumber != 0)
|
||||||
|
cardListButtonGroup.entries.find(e => e.value.number == oldCardNumber || e.value.altNumber == oldCardNumber)!.button.enabled = true;
|
||||||
|
|
||||||
|
const button3 = createDeckEditEmptySlotButton();
|
||||||
|
button3.checked = true;
|
||||||
|
|
||||||
|
deckEditCardButtons.replace(index, button3, 0);
|
||||||
|
deckEditUpdateSize();
|
||||||
|
|
||||||
|
cardList.listElement.parentElement!.classList.remove('selecting');
|
||||||
|
if (!deckModified) {
|
||||||
|
deckModified = true;
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload_deckEditor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function deckEditInitStageDatabase(stages: Stage[]) {
|
function deckEditInitStageDatabase(stages: Stage[]) {
|
||||||
for (const stage of stages) {
|
for (const stage of stages) {
|
||||||
const button = new StageButton(stage);
|
const button = new StageButton(stage);
|
||||||
|
|
@ -108,6 +138,7 @@ function editDeck() {
|
||||||
for (const entry of cardListButtonGroup.entries)
|
for (const entry of cardListButtonGroup.entries)
|
||||||
entry.button.enabled = !selectedDeck.cards.includes(entry.value.number);
|
entry.button.enabled = !selectedDeck.cards.includes(entry.value.number);
|
||||||
|
|
||||||
|
reloadCustomCards();
|
||||||
deckEditUpdateSize();
|
deckEditUpdateSize();
|
||||||
cardList.clearFilter();
|
cardList.clearFilter();
|
||||||
editingDeck = true;
|
editingDeck = true;
|
||||||
|
|
@ -115,6 +146,16 @@ function editDeck() {
|
||||||
selectFirstEmptySlot();
|
selectFirstEmptySlot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadCustomCards() {
|
||||||
|
if (!cardDatabase.customCardsModified) return;
|
||||||
|
cardList.removeAllCustomCards();
|
||||||
|
testAllCardsList.removeAllCustomCards();
|
||||||
|
for (const card of cardDatabase.customCards) {
|
||||||
|
addCardToDeckEditor(card);
|
||||||
|
addTestCard(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectFirstEmptySlot() {
|
function selectFirstEmptySlot() {
|
||||||
let found = false;
|
let found = false;
|
||||||
for (const el of deckEditCardButtons.entries) {
|
for (const el of deckEditCardButtons.entries) {
|
||||||
|
|
@ -138,6 +179,7 @@ function createDeckEditCardButton(cardNumber: number) {
|
||||||
button2.checked = button2.card.number == cardNumber;
|
button2.checked = button2.card.number == cardNumber;
|
||||||
}
|
}
|
||||||
cardList.listElement.parentElement!.classList.add('selecting');
|
cardList.listElement.parentElement!.classList.add('selecting');
|
||||||
|
deckEditorRemoveButton.hidden = false;
|
||||||
});
|
});
|
||||||
button.buttonElement.addEventListener('dragstart', e => {
|
button.buttonElement.addEventListener('dragstart', e => {
|
||||||
if (e.dataTransfer == null) return;
|
if (e.dataTransfer == null) return;
|
||||||
|
|
@ -212,11 +254,12 @@ function createDeckEditEmptySlotButton() {
|
||||||
const buttonElement = document.createElement('button');
|
const buttonElement = document.createElement('button');
|
||||||
const button = new CheckButton(buttonElement);
|
const button = new CheckButton(buttonElement);
|
||||||
buttonElement.type = 'button';
|
buttonElement.type = 'button';
|
||||||
buttonElement.className = 'card emptySlot';
|
buttonElement.className = 'cardButton emptySlot';
|
||||||
buttonElement.addEventListener('click', () => {
|
buttonElement.addEventListener('click', () => {
|
||||||
for (const button2 of cardList.cardButtons)
|
for (const button2 of cardList.cardButtons)
|
||||||
button2.checked = false;
|
button2.checked = false;
|
||||||
cardList.listElement.parentElement!.classList.add('selecting');
|
cardList.listElement.parentElement!.classList.add('selecting');
|
||||||
|
deckEditorRemoveButton.hidden = true;
|
||||||
});
|
});
|
||||||
buttonElement.addEventListener('dragenter', e => e.preventDefault());
|
buttonElement.addEventListener('dragenter', e => e.preventDefault());
|
||||||
buttonElement.addEventListener('dragover', deckEditCardButton_dragover);
|
buttonElement.addEventListener('dragover', deckEditCardButton_dragover);
|
||||||
|
|
@ -224,24 +267,45 @@ function createDeckEditEmptySlotButton() {
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deckSortCompare(reverse: boolean, numberA: number, numberB: number) {
|
||||||
|
// Any card is always sorted before empty slots.
|
||||||
|
if (numberA == 0) return numberB == 0 ? 0 : 1;
|
||||||
|
if (numberB == 0) return -1;
|
||||||
|
|
||||||
|
const cardA = cardDatabase.get(numberA);
|
||||||
|
const cardB = cardDatabase.get(numberB);
|
||||||
|
if (userConfig.specialWeaponSorting != SpecialWeaponSorting.InOrder) {
|
||||||
|
if (cardA.isSpecialWeapon && !cardB.isSpecialWeapon)
|
||||||
|
return ((userConfig.specialWeaponSorting == SpecialWeaponSorting.Last) != reverse) ? 1 : -1;
|
||||||
|
else if (cardB.isSpecialWeapon && !cardA.isSpecialWeapon)
|
||||||
|
return ((userConfig.specialWeaponSorting == SpecialWeaponSorting.Last) != reverse) ? -1 : 1;
|
||||||
|
}
|
||||||
|
const result = CardList.compareBySize(cardA, cardB);
|
||||||
|
return reverse ? -result : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
deckEditMenuButton.addEventListener('click', () => {
|
||||||
|
deckEditMenu.classList.toggle('showing');
|
||||||
|
});
|
||||||
|
|
||||||
|
deckEditMenu.addEventListener('click', () => {
|
||||||
|
deckEditMenu.classList.remove('showing');
|
||||||
|
});
|
||||||
|
|
||||||
deckSortButton.addEventListener('click', _ => {
|
deckSortButton.addEventListener('click', _ => {
|
||||||
|
// Check whether the deck is already sorted so that the order will be reversed if so.
|
||||||
let isSorted = true;
|
let isSorted = true;
|
||||||
let lastCardNumber = deckEditCardButtons.entries[0].value;
|
let lastCardNumber = deckEditCardButtons.entries[0].value;
|
||||||
for (let i = 1; i < deckEditCardButtons.entries.length; i++) {
|
for (let i = 1; i < deckEditCardButtons.entries.length; i++) {
|
||||||
const entry = deckEditCardButtons.entries[i];
|
const entry = deckEditCardButtons.entries[i];
|
||||||
if (lastCardNumber == 0 ? entry.value != 0 : (entry.value != 0 && cardDatabase.get(entry.value).size < cardDatabase.get(lastCardNumber).size)) {
|
if (lastCardNumber == 0 ? entry.value != 0 : (entry.value != 0 && deckSortCompare(false, entry.value, lastCardNumber) < 0)) {
|
||||||
isSorted = false;
|
isSorted = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
lastCardNumber = entry.value;
|
lastCardNumber = entry.value;
|
||||||
}
|
}
|
||||||
const comparer = CardList.cardSortOrders['size'];
|
|
||||||
if (isSorted)
|
|
||||||
// If the deck is already sorted, reverse the order.
|
|
||||||
deckEditCardButtons.entries.sort((a, b) => a.value == 0 ? (b.value == 0 ? 0 : 1) : (b.value == 0 ? -1 : comparer(cardDatabase.get(b.value), cardDatabase.get(a.value))));
|
|
||||||
else
|
|
||||||
deckEditCardButtons.entries.sort((a, b) => a.value == 0 ? (b.value == 0 ? 0 : 1) : (b.value == 0 ? -1 : comparer(cardDatabase.get(a.value), cardDatabase.get(b.value))));
|
|
||||||
|
|
||||||
|
deckEditCardButtons.entries.sort((a, b) => deckSortCompare(isSorted, a.value, b.value));
|
||||||
clearChildren(deckCardListEdit);
|
clearChildren(deckCardListEdit);
|
||||||
for (const button of deckEditCardButtons.buttons)
|
for (const button of deckEditCardButtons.buttons)
|
||||||
deckCardListEdit.appendChild(button.buttonElement);
|
deckCardListEdit.appendChild(button.buttonElement);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ const deckCardListView = document.getElementById('deckCardListView')!;
|
||||||
const addDeckControls = document.getElementById('addDeckControls')!;
|
const addDeckControls = document.getElementById('addDeckControls')!;
|
||||||
const newDeckButton = document.getElementById('newDeckButton') as HTMLButtonElement;
|
const newDeckButton = document.getElementById('newDeckButton') as HTMLButtonElement;
|
||||||
const importDeckButton = document.getElementById('importDeckButton') as HTMLButtonElement;
|
const importDeckButton = document.getElementById('importDeckButton') as HTMLButtonElement;
|
||||||
|
|
||||||
|
const deckViewMenu = document.getElementById('deckViewMenu')!;
|
||||||
|
const deckViewMenuButton = document.getElementById('deckViewMenuButton') as HTMLButtonElement;
|
||||||
const deckSleevesButton = document.getElementById('deckSleevesButton') as HTMLButtonElement;
|
const deckSleevesButton = document.getElementById('deckSleevesButton') as HTMLButtonElement;
|
||||||
const deckEditButton = document.getElementById('deckEditButton') as HTMLButtonElement;
|
const deckEditButton = document.getElementById('deckEditButton') as HTMLButtonElement;
|
||||||
const deckListTestButton = document.getElementById('deckListTestButton') as HTMLButtonElement;
|
const deckListTestButton = document.getElementById('deckListTestButton') as HTMLButtonElement;
|
||||||
|
|
@ -88,10 +91,11 @@ deckViewBackButton.addEventListener('click', e => {
|
||||||
clearChildren(deckCardListView);
|
clearChildren(deckCardListView);
|
||||||
deselectDeck();
|
deselectDeck();
|
||||||
deckListPage.classList.remove('showingDeck');
|
deckListPage.classList.remove('showingDeck');
|
||||||
|
deckViewMenu.classList.remove('showing');
|
||||||
});
|
});
|
||||||
|
|
||||||
function saveDecks() {
|
function saveDecks() {
|
||||||
const json = JSON.stringify(decks.filter(d => !d.isReadOnly), [ 'name', 'cards', 'sleeves' ]);
|
const json = JSON.stringify(decks.filter(d => !d.isReadOnly), [ 'name', 'cards', 'sleeves', 'upgrades' ]);
|
||||||
localStorage.setItem('decks', json);
|
localStorage.setItem('decks', json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,13 +103,13 @@ function saveDecks() {
|
||||||
const decksString = localStorage.getItem('decks');
|
const decksString = localStorage.getItem('decks');
|
||||||
if (decksString) {
|
if (decksString) {
|
||||||
for (const deck of JSON.parse(decksString)) {
|
for (const deck of JSON.parse(decksString)) {
|
||||||
decks.push(new SavedDeck(deck.name, deck.sleeves ?? 0, deck.cards, deck.upgrades ?? new Array(15), false));
|
decks.push(SavedDeck.fromJson(deck));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const lastDeckString = localStorage.getItem('lastDeck');
|
const lastDeckString = localStorage.getItem('lastDeck');
|
||||||
const lastDeck = lastDeckString?.split(/\+/)?.map(s => parseInt(s));
|
const lastDeck = lastDeckString?.split(/\+/)?.map(s => parseInt(s));
|
||||||
if (lastDeck && lastDeck.length == 15) {
|
if (lastDeck && lastDeck.length == 15) {
|
||||||
decks.push(new SavedDeck('Custom Deck', 0, lastDeck, new Array(15), false));
|
decks.push(new SavedDeck('Custom Deck', 0, lastDeck, new Array(15).fill(1), false));
|
||||||
saveDecks();
|
saveDecks();
|
||||||
}
|
}
|
||||||
localStorage.removeItem('lastDeck');
|
localStorage.removeItem('lastDeck');
|
||||||
|
|
@ -132,7 +136,7 @@ function createDeckButton(deck: SavedDeck) {
|
||||||
const index = decks.indexOf(deck);
|
const index = decks.indexOf(deck);
|
||||||
draggingDeckButton = buttonElement;
|
draggingDeckButton = buttonElement;
|
||||||
e.dataTransfer.effectAllowed = 'copyMove';
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
e.dataTransfer.setData('text/plain', JSON.stringify(deck, [ 'name', 'cards' ]));
|
e.dataTransfer.setData('text/plain', serialiseDecks([ deck ]));
|
||||||
e.dataTransfer.setData('application/tableturf-deck-index', index.toString());
|
e.dataTransfer.setData('application/tableturf-deck-index', index.toString());
|
||||||
buttonElement.classList.add('dragging');
|
buttonElement.classList.add('dragging');
|
||||||
});
|
});
|
||||||
|
|
@ -207,19 +211,49 @@ function deckButton_drop(e: DragEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function importDecks(decksToImport: (SavedDeck | number[])[]) {
|
function importDecks(decksToImport: DeckFullExport) {
|
||||||
let newSelectedDeck: SavedDeck | null = null;
|
let newSelectedDeck: SavedDeck | null = null;
|
||||||
for (const el of decksToImport) {
|
|
||||||
|
// Merge custom cards.
|
||||||
|
const customCardNumbers: {[key: number]: number} = { };
|
||||||
|
if (decksToImport.customCards) {
|
||||||
|
console.log('Merging custom cards...');
|
||||||
|
for (const key in decksToImport.customCards) {
|
||||||
|
const incomingCard = Card.fromJson(decksToImport.customCards[key]);
|
||||||
|
const existingCard = cardDatabase.customCards.find(c => c.isTheSameAs(incomingCard));
|
||||||
|
if (existingCard) {
|
||||||
|
console.log(`Incoming card (${key}) ${incomingCard.name} matches existing (${existingCard.number}).`);
|
||||||
|
customCardNumbers[key] = existingCard.number;
|
||||||
|
} else {
|
||||||
|
const newNumber = CUSTOM_CARD_START - cardDatabase.customCards.length;
|
||||||
|
console.log(`Adding incoming card (${key}) ${incomingCard.name} as (${newNumber}).`);
|
||||||
|
incomingCard.number = newNumber;
|
||||||
|
cardDatabase.customCards.push(incomingCard);
|
||||||
|
addCardToGallery(incomingCard);
|
||||||
|
cardDatabase.customCardsModified = true;
|
||||||
|
customCardNumbers[key] = incomingCard.number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Done merging custom cards.');
|
||||||
|
saveCustomCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import decks.
|
||||||
|
for (const el of decksToImport.decks) {
|
||||||
let deck;
|
let deck;
|
||||||
if (el instanceof Array)
|
if (Array.isArray(el))
|
||||||
deck = new SavedDeck(`Imported Deck ${decks.length + 1}`, 0, el, new Array(15), false);
|
deck = new SavedDeck(`Imported Deck ${decks.length + 1}`, 0, el, new Array(15).fill(1), false);
|
||||||
else {
|
else {
|
||||||
deck = el;
|
deck = el;
|
||||||
deck.sleeves ??= 0;
|
deck.sleeves ??= 0;
|
||||||
deck.upgrades ??= new Array(15);
|
deck.upgrades ??= new Array(15).fill(1);
|
||||||
deck.isReadOnly = false;
|
deck.isReadOnly = false;
|
||||||
if (!deck.name) deck.name = `Imported Deck ${decks.length + 1}`;
|
if (!deck.name) deck.name = `Imported Deck ${decks.length + 1}`;
|
||||||
}
|
}
|
||||||
|
for (let i = 0; i < deck.cards.length; i++) {
|
||||||
|
if (deck.cards[i] <= CUSTOM_CARD_START)
|
||||||
|
deck.cards[i] = customCardNumbers[deck.cards[i]];
|
||||||
|
}
|
||||||
createDeckButton(deck);
|
createDeckButton(deck);
|
||||||
decks.push(deck);
|
decks.push(deck);
|
||||||
newSelectedDeck ??= deck;
|
newSelectedDeck ??= deck;
|
||||||
|
|
@ -228,13 +262,14 @@ function importDecks(decksToImport: (SavedDeck | number[])[]) {
|
||||||
selectedDeck = newSelectedDeck;
|
selectedDeck = newSelectedDeck;
|
||||||
deckButtons.deselect();
|
deckButtons.deselect();
|
||||||
deckButtons.entries.find(e => e.value == newSelectedDeck)!.button.checked = true;
|
deckButtons.entries.find(e => e.value == newSelectedDeck)!.button.checked = true;
|
||||||
|
deckButtons.value = newSelectedDeck;
|
||||||
selectDeck();
|
selectDeck();
|
||||||
saveDecks();
|
saveDecks();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newDeckButton.addEventListener('click', () => {
|
newDeckButton.addEventListener('click', () => {
|
||||||
selectedDeck = new SavedDeck(`Deck ${decks.length + 1}`, 0, new Array(15), new Array(15), false);
|
selectedDeck = new SavedDeck(`Deck ${decks.length + 1}`, 0, new Array(15).fill(0), new Array(15).fill(1), false);
|
||||||
createDeckButton(selectedDeck);
|
createDeckButton(selectedDeck);
|
||||||
decks.push(selectedDeck);
|
decks.push(selectedDeck);
|
||||||
editDeck();
|
editDeck();
|
||||||
|
|
@ -266,7 +301,7 @@ deckImportForm.addEventListener('submit', e => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseDecksForImport(s: string) {
|
function parseDecksForImport(s: string) : DeckFullExport {
|
||||||
let isKoishiShareUrl = false;
|
let isKoishiShareUrl = false;
|
||||||
const pos = s.indexOf('deck=');
|
const pos = s.indexOf('deck=');
|
||||||
if (pos >= 0) {
|
if (pos >= 0) {
|
||||||
|
|
@ -279,25 +314,40 @@ function parseDecksForImport(s: string) {
|
||||||
if (data.length > 15 || data.find(i => typeof(i) != 'number' || !cardDatabase.isValidCardNumber(isKoishiShareUrl ? i : i + 1)))
|
if (data.length > 15 || data.find(i => typeof(i) != 'number' || !cardDatabase.isValidCardNumber(isKoishiShareUrl ? i : i + 1)))
|
||||||
throw new SyntaxError('Invalid deck data');
|
throw new SyntaxError('Invalid deck data');
|
||||||
if (isKoishiShareUrl)
|
if (isKoishiShareUrl)
|
||||||
return [ data ]; // tableturf.koishi.top share URL
|
return { decks: [ data ] }; // tableturf.koishi.top share URL
|
||||||
else
|
else
|
||||||
return [ data.map(n => n + 1) ]; // Tooltip export data
|
return { decks: [ data.map(n => n + 1) ] }; // Tooltip export data
|
||||||
} else {
|
} else {
|
||||||
for (const deck of data) {
|
for (const deck of data) {
|
||||||
if (typeof(deck) != 'object' || !Array.isArray(deck.cards) || deck.cards.length > 15 || (deck.cards as any[]).find((i => typeof(i) != 'number' || !cardDatabase.isValidCardNumber(i))))
|
if (typeof(deck) != 'object' || !Array.isArray(deck.cards) || deck.cards.length > 15 || (deck.cards as any[]).find((i => typeof(i) != 'number' || !cardDatabase.isValidCardNumber(i))))
|
||||||
throw new SyntaxError('Invalid JSON deck');
|
throw new SyntaxError('Invalid JSON deck');
|
||||||
}
|
}
|
||||||
return data; // Our export data
|
return { decks: data.map(SavedDeck.fromJson) }; // Our export data without custom cards
|
||||||
}
|
}
|
||||||
} else if (typeof(data) == 'object') {
|
} else if (typeof(data) == 'object') {
|
||||||
if (!Array.isArray(data.cards) || data.cards.length > 15 || (data.cards as any[]).find((i => typeof(i) != 'number' || !cardDatabase.isValidCardNumber(i))))
|
if ('decks' in data) {
|
||||||
throw new SyntaxError('Invalid JSON deck');
|
// Our export data with custom cards
|
||||||
return [ data ]; // Our old export data
|
const fullExport = data as DeckFullExport;
|
||||||
|
fullExport.decks = fullExport.decks.map(SavedDeck.fromJson);
|
||||||
|
return fullExport;
|
||||||
|
} else {
|
||||||
|
// Our old export data
|
||||||
|
if (!Array.isArray(data.cards) || data.cards.length > 15 || (data.cards as any[]).find((i => typeof(i) != 'number' || !cardDatabase.isValidOfficialCardNumber(i))))
|
||||||
|
throw new SyntaxError('Invalid JSON deck');
|
||||||
|
return { decks: [ SavedDeck.fromJson(data) ] };
|
||||||
|
}
|
||||||
} else
|
} else
|
||||||
throw new SyntaxError('Invalid JSON deck');
|
throw new SyntaxError('Invalid JSON deck');
|
||||||
// TODO: add support for tblturf.ink
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deckViewMenuButton.addEventListener('click', () => {
|
||||||
|
deckViewMenu.classList.toggle('showing');
|
||||||
|
});
|
||||||
|
|
||||||
|
deckViewMenu.addEventListener('click', () => {
|
||||||
|
deckViewMenu.classList.remove('showing');
|
||||||
|
});
|
||||||
|
|
||||||
deckSleevesButton.addEventListener('click', () => {
|
deckSleevesButton.addEventListener('click', () => {
|
||||||
if (selectedDeck == null) return;
|
if (selectedDeck == null) return;
|
||||||
deckSleevesButtons[selectedDeck.sleeves].checked = true;
|
deckSleevesButtons[selectedDeck.sleeves].checked = true;
|
||||||
|
|
@ -370,9 +420,47 @@ function deselectDeck() {
|
||||||
deckListPage.classList.remove('showingDeck');
|
deckListPage.classList.remove('showingDeck');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deckExportJsonReplacer(key: string, value: any) {
|
||||||
|
switch (key) {
|
||||||
|
case 'isReadOnly':
|
||||||
|
case 'number':
|
||||||
|
case 'altNumber':
|
||||||
|
case 'artFileName':
|
||||||
|
case 'size':
|
||||||
|
case 'textScale':
|
||||||
|
case 'isVariantOf':
|
||||||
|
case 'minX':
|
||||||
|
case 'minY':
|
||||||
|
case 'maxX':
|
||||||
|
case 'maxY':
|
||||||
|
return undefined;
|
||||||
|
case 'line1':
|
||||||
|
case 'line2':
|
||||||
|
return value ?? undefined; // Omit null values.
|
||||||
|
case 'imageUrl':
|
||||||
|
// Custom cards store image data here, so include it if it is a data URI.
|
||||||
|
return value && (<string> value).startsWith('data:') ? value : undefined;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseDecks(decks: SavedDeck[]) {
|
||||||
|
let customCards: {[key: number]: Card} | null = null;
|
||||||
|
for (const deck of decks) {
|
||||||
|
for (const number of deck.cards) {
|
||||||
|
if (number <= CUSTOM_CARD_START) {
|
||||||
|
customCards ??= { };
|
||||||
|
customCards[number] = cardDatabase.customCards[CUSTOM_CARD_START - number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(customCards != null ? { decks, customCards } : decks, deckExportJsonReplacer);
|
||||||
|
}
|
||||||
|
|
||||||
deckExportButton.addEventListener('click', () => {
|
deckExportButton.addEventListener('click', () => {
|
||||||
if (selectedDeck == null) return;
|
if (selectedDeck == null) return;
|
||||||
const json = JSON.stringify(selectedDeck, [ 'name', 'cards' ]);
|
const json = serialiseDecks([ selectedDeck ]);
|
||||||
deckExportTextBox.value = json;
|
deckExportTextBox.value = json;
|
||||||
deckExportCopyButton.innerText = 'Copy';
|
deckExportCopyButton.innerText = 'Copy';
|
||||||
deckExportDialog.showModal();
|
deckExportDialog.showModal();
|
||||||
|
|
@ -395,7 +483,7 @@ deckRenameButton.addEventListener('click', () => {
|
||||||
|
|
||||||
deckCopyButton.addEventListener('click', () => {
|
deckCopyButton.addEventListener('click', () => {
|
||||||
if (selectedDeck == null) return;
|
if (selectedDeck == null) return;
|
||||||
importDecks([ new SavedDeck(`${selectedDeck.name} - Copy`, selectedDeck.sleeves, Array.from(selectedDeck.cards), Array.from(selectedDeck.upgrades), false) ]);
|
importDecks({ decks: [ new SavedDeck(`${selectedDeck.name} - Copy`, selectedDeck.sleeves, Array.from(selectedDeck.cards), Array.from(selectedDeck.upgrades), false) ] });
|
||||||
});
|
});
|
||||||
|
|
||||||
deckDeleteButton.addEventListener('click', () => {
|
deckDeleteButton.addEventListener('click', () => {
|
||||||
|
|
@ -440,7 +528,7 @@ deckImportFileBox.addEventListener('change', async () => {
|
||||||
if (deckImportFileBox.files && deckImportFileBox.files.length > 0) {
|
if (deckImportFileBox.files && deckImportFileBox.files.length > 0) {
|
||||||
try {
|
try {
|
||||||
const bitmaps = await Promise.all(Array.from(deckImportFileBox.files, f => createImageBitmap(f)));
|
const bitmaps = await Promise.all(Array.from(deckImportFileBox.files, f => createImageBitmap(f)));
|
||||||
importDecks(bitmaps.map(getCardListFromImageBitmap));
|
importDecks({ decks: bitmaps.map(getCardListFromImageBitmap) });
|
||||||
deckImportDialog.close();
|
deckImportDialog.close();
|
||||||
} catch (ex: any) {
|
} catch (ex: any) {
|
||||||
deckImportErrorBox.innerText = ex.message;
|
deckImportErrorBox.innerText = ex.message;
|
||||||
|
|
@ -451,7 +539,7 @@ deckImportFileBox.addEventListener('change', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
deckExportAllButton.addEventListener('click', () => {
|
deckExportAllButton.addEventListener('click', () => {
|
||||||
const json = JSON.stringify(decks.filter(d => !d.isReadOnly), [ 'name', 'cards' ]);
|
const json = serialiseDecks(decks.filter(d => !d.isReadOnly));
|
||||||
deckExportTextBox.value = json;
|
deckExportTextBox.value = json;
|
||||||
deckExportCopyButton.innerText = 'Copy';
|
deckExportCopyButton.innerText = 'Copy';
|
||||||
deckExportDialog.showModal();
|
deckExportDialog.showModal();
|
||||||
|
|
|
||||||
658
TableturfBattleClient/src/Pages/GalleryPage.ts
Normal file
|
|
@ -0,0 +1,658 @@
|
||||||
|
const galleryCardList = CardList.fromId<CardDisplay>('galleryCardList', 'gallerySortBox', 'galleryFilterBox');
|
||||||
|
const galleryBackButton = document.getElementById('galleryBackButton') as HTMLLinkElement;
|
||||||
|
const galleryCardDialog = document.getElementById('galleryCardDialog') as HTMLDialogElement;
|
||||||
|
const galleryCardDeleteDialog = document.getElementById('galleryCardDeleteDialog') as HTMLDialogElement;
|
||||||
|
|
||||||
|
const galleryNewCustomCardButton = document.getElementById('galleryNewCustomCardButton') as HTMLButtonElement;
|
||||||
|
const galleryChecklistBox = document.getElementById('galleryChecklistBox') as HTMLInputElement;
|
||||||
|
const bitsToCompleteField = document.getElementById('bitsToCompleteField') as HTMLElement;
|
||||||
|
|
||||||
|
let galleryCardDisplay: CardDisplay | null = null;
|
||||||
|
let gallerySelectedCardDisplay: CardDisplay | null = null;
|
||||||
|
const galleryCardEditor = document.getElementById('galleryCardEditor') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorImageFile = document.getElementById('galleryCardEditorImageFile') as HTMLInputElement;
|
||||||
|
const galleryCardEditorImageSelectButton = document.getElementById('galleryCardEditorImageSelectButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorImageClearButton = document.getElementById('galleryCardEditorImageClearButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorRarityBox = document.getElementById('galleryCardEditorRarityBox') as HTMLSelectElement;
|
||||||
|
const galleryCardEditorColour1 = document.getElementById('galleryCardEditorColour1') as HTMLInputElement;
|
||||||
|
const galleryCardEditorColour2 = document.getElementById('galleryCardEditorColour2') as HTMLInputElement;
|
||||||
|
const galleryCardEditorColourPresetBox = document.getElementById('galleryCardEditorColourPresetBox') as HTMLSelectElement;
|
||||||
|
const galleryCardEditorName = document.getElementById('galleryCardEditorName') as HTMLTextAreaElement;
|
||||||
|
const galleryCardEditorGridButtons: HTMLButtonElement[][] = [ ];
|
||||||
|
const galleryCardEditorSpecialCostButtons: HTMLButtonElement[] = [ ];
|
||||||
|
const galleryCardEditorSpecialCost = document.getElementById('galleryCardEditorSpecialCost') as HTMLElement;
|
||||||
|
const galleryCardEditorSpecialCostDefaultBox = document.getElementById('galleryCardEditorSpecialCostDefaultBox') as HTMLInputElement;
|
||||||
|
const galleryCardEditorEditButton = document.getElementById('galleryCardEditorEditButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorSubmitButton = document.getElementById('galleryCardEditorSubmitButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorDeleteButton = document.getElementById('galleryCardEditorDeleteButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorSnapshotButton = document.getElementById('galleryCardEditorSnapshotButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorCancelButton = document.getElementById('galleryCardEditorCancelButton') as HTMLButtonElement;
|
||||||
|
const galleryCardEditorDeleteYesButton = document.getElementById('galleryCardEditorDeleteYesButton') as HTMLButtonElement;
|
||||||
|
|
||||||
|
const colourPresets: {[key: string]: [ Colour, Colour ]} = {
|
||||||
|
"Default": [ Card.DEFAULT_INK_COLOUR_1, Card.DEFAULT_INK_COLOUR_2 ],
|
||||||
|
"Octarian": [ { r: 121, g: 111, b: 174 }, { r: 166, g: 105, b: 169 } ],
|
||||||
|
"Salmonid": [ { r: 193, g: 111, b: 98 }, { r: 84, g: 142, b: 122 } ],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownedCards: {[key: number]: number} = { 6: 0, 34: 0, 159: 0, 13: 0, 45: 0, 137: 0, 22: 0, 52: 0, 141: 0, 28: 0, 55: 0, 103: 0, 40: 0, 56: 0, 92: 0 };
|
||||||
|
let lastGridButton: HTMLButtonElement | null = null;
|
||||||
|
let customCardSize = 0;
|
||||||
|
let customCardSpecialCost = 0;
|
||||||
|
|
||||||
|
function showCardList() {
|
||||||
|
showPage('gallery');
|
||||||
|
}
|
||||||
|
|
||||||
|
function galleryInitCardDatabase(cards: Card[]) {
|
||||||
|
for (const card of cards.concat(cardDatabase.customCards)) {
|
||||||
|
addCardToGallery(card);
|
||||||
|
}
|
||||||
|
updateBitsToComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCardToGallery(card: Card) {
|
||||||
|
const display = createGalleryCardDisplay(card);
|
||||||
|
galleryCardList.add(display);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGalleryCardDisplay(card: Card) {
|
||||||
|
const display = new CardDisplay(card, 1, 'button');
|
||||||
|
|
||||||
|
const cardNumber = document.createElement('div');
|
||||||
|
cardNumber.className = 'cardNumber';
|
||||||
|
cardNumber.innerText = card.number >= 0 ? `No. ${card.number}` : card.isCustom ? 'Custom' : 'Upcoming';
|
||||||
|
display.element.insertBefore(cardNumber, display.element.firstChild);
|
||||||
|
|
||||||
|
display.element.addEventListener('click', () => {
|
||||||
|
if (galleryChecklistBox.checked) {
|
||||||
|
if (card.number <= 0) return;
|
||||||
|
if (card.number in ownedCards) {
|
||||||
|
delete ownedCards[card.number];
|
||||||
|
display.element.classList.add('unowned');
|
||||||
|
} else {
|
||||||
|
ownedCards[card.number] = 0;
|
||||||
|
display.element.classList.remove('unowned');
|
||||||
|
}
|
||||||
|
updateBitsToComplete();
|
||||||
|
saveChecklist();
|
||||||
|
} else {
|
||||||
|
gallerySelectedCardDisplay = display;
|
||||||
|
openGalleryCardView(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardInGallery(card: Card) {
|
||||||
|
const display = createGalleryCardDisplay(card);
|
||||||
|
galleryCardList.update(display, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGalleryCardView(card: Card) {
|
||||||
|
const existingEl = galleryCardDialog.firstElementChild;
|
||||||
|
if (existingEl && existingEl.tagName != 'FORM')
|
||||||
|
galleryCardDialog.removeChild(existingEl);
|
||||||
|
const display = new CardDisplay(card, 1);
|
||||||
|
galleryCardDisplay = display;
|
||||||
|
galleryCardDialog.insertBefore(display.element, galleryCardDialog.firstChild);
|
||||||
|
|
||||||
|
galleryCardEditor.parentElement?.removeChild(galleryCardEditor);
|
||||||
|
display.element.appendChild(galleryCardEditor);
|
||||||
|
galleryCardEditor.hidden = true;
|
||||||
|
display.element.classList.remove('editing');
|
||||||
|
galleryCardEditorEditButton.hidden = !card.isCustom;
|
||||||
|
galleryCardEditorDeleteButton.hidden = !card.isCustom;
|
||||||
|
galleryCardEditorSubmitButton.hidden = true;
|
||||||
|
galleryCardEditorCancelButton.innerText = 'Close';
|
||||||
|
|
||||||
|
if (card.isCustom) {
|
||||||
|
galleryCardEditorRarityBox.value = card.rarity.toString();
|
||||||
|
galleryCardEditorColour1.value = `#${card.inkColour1.r.toString(16).padStart(2, '0')}${card.inkColour1.g.toString(16).padStart(2, '0')}${card.inkColour1.b.toString(16).padStart(2, '0')}`;
|
||||||
|
galleryCardEditorColour2.value = `#${card.inkColour2.r.toString(16).padStart(2, '0')}${card.inkColour2.g.toString(16).padStart(2, '0')}${card.inkColour2.b.toString(16).padStart(2, '0')}`;
|
||||||
|
updateSelectedPreset([card.inkColour1, card.inkColour2]);
|
||||||
|
|
||||||
|
galleryCardEditorName.value = card.line2 == null ? card.name : `${card.line1}\n${card.line2}`;
|
||||||
|
|
||||||
|
let size = 0; let hasSpecialSpace = false;
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
galleryCardEditorGridButtons[y][x].dataset.state = card.grid[y][x].toString();
|
||||||
|
switch (card.grid[y][x]) {
|
||||||
|
case Space.Ink1:
|
||||||
|
size++;
|
||||||
|
break;
|
||||||
|
case Space.SpecialInactive1:
|
||||||
|
size++;
|
||||||
|
hasSpecialSpace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultSpecialCost =
|
||||||
|
size <= 3 ? 1
|
||||||
|
: size <= 5 ? 2
|
||||||
|
: size <= 8 ? 3
|
||||||
|
: size <= 11 ? 4
|
||||||
|
: size <= 15 ? 5
|
||||||
|
: 6;
|
||||||
|
if (!hasSpecialSpace && defaultSpecialCost > 3)
|
||||||
|
defaultSpecialCost = 3;
|
||||||
|
|
||||||
|
galleryCardEditorSpecialCostDefaultBox.checked = card.specialCost == defaultSpecialCost;
|
||||||
|
customCardSpecialCost = card.specialCost;
|
||||||
|
for (let i = 0; i < galleryCardEditorSpecialCostButtons.length; i++) {
|
||||||
|
const button = galleryCardEditorSpecialCostButtons[i];
|
||||||
|
if (parseInt(button.dataset.value!) <= card.specialCost)
|
||||||
|
button.classList.add('active');
|
||||||
|
else
|
||||||
|
button.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCustomCardSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryCardDialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedPreset(selectedColours: Colour[]) {
|
||||||
|
for (const key in colourPresets) {
|
||||||
|
const colours = colourPresets[key];
|
||||||
|
if (selectedColours[0].r == colours[0].r && selectedColours[0].g == colours[0].g && selectedColours[0].b == colours[0].b
|
||||||
|
&& selectedColours[1].r == colours[1].r && selectedColours[1].g == colours[1].g && selectedColours[1].b == colours[1].b) {
|
||||||
|
galleryCardEditorColourPresetBox.value = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
galleryCardEditorColourPresetBox.value = 'Custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingCustomCard() {
|
||||||
|
galleryCardEditor.hidden = false;
|
||||||
|
galleryCardDisplay?.element.classList.add('editing');
|
||||||
|
galleryCardEditorEditButton.hidden = true;
|
||||||
|
galleryCardEditorDeleteButton.hidden = true;
|
||||||
|
galleryCardEditorSubmitButton.hidden = false;
|
||||||
|
galleryCardEditorCancelButton.innerText = 'Cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryBackButton.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
showPage('preGame');
|
||||||
|
|
||||||
|
if (canPushState) {
|
||||||
|
try {
|
||||||
|
history.pushState(null, '', '.');
|
||||||
|
} catch {
|
||||||
|
canPushState = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (location.hash)
|
||||||
|
location.hash = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryChecklistBox.addEventListener('change', () => {
|
||||||
|
if (galleryChecklistBox.checked) {
|
||||||
|
for (const cardDisplay of galleryCardList.cardButtons) {
|
||||||
|
if (cardDisplay.card.number in ownedCards)
|
||||||
|
cardDisplay.element.classList.remove('unowned');
|
||||||
|
else
|
||||||
|
cardDisplay.element.classList.add('unowned');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const cardDisplay of galleryCardList.cardButtons)
|
||||||
|
cardDisplay.element.classList.remove('unowned');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBitsToComplete() {
|
||||||
|
if (!cardDatabase.cards) throw new Error('Card database not loaded');
|
||||||
|
let bitsRequired = 0;
|
||||||
|
for (const card of cardDatabase.cards) {
|
||||||
|
if (card.isUpcoming || card.number in ownedCards) continue;
|
||||||
|
switch (card.rarity) {
|
||||||
|
case Rarity.Fresh: bitsRequired += 40; break;
|
||||||
|
case Rarity.Rare: bitsRequired += 15; break;
|
||||||
|
default: bitsRequired += 5; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bitsToCompleteField.innerText = bitsRequired.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
for (var i = 0; ; i++) {
|
||||||
|
if (!(i in Rarity)) break;
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = i.toString();
|
||||||
|
option.innerText = Rarity[i];
|
||||||
|
galleryCardEditorRarityBox.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k in colourPresets) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.innerText = k;
|
||||||
|
galleryCardEditorColourPresetBox.appendChild(option);
|
||||||
|
}
|
||||||
|
const optionCustom = document.createElement('option');
|
||||||
|
optionCustom.innerText = 'Custom';
|
||||||
|
galleryCardEditorColourPresetBox.appendChild(optionCustom);
|
||||||
|
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
const row = [ ];
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.state = Space.Empty.toString();
|
||||||
|
button.dataset.x = x.toString();
|
||||||
|
button.dataset.y = y.toString();
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const state = parseInt(button.dataset.state ?? '0');
|
||||||
|
switch (state) {
|
||||||
|
case Space.Empty:
|
||||||
|
button.dataset.state = Space.Ink1.toString();
|
||||||
|
break;
|
||||||
|
case Space.Ink1:
|
||||||
|
if (lastGridButton == button) {
|
||||||
|
// When a space is pressed twice, move the special space there.
|
||||||
|
for (const row of galleryCardEditorGridButtons) {
|
||||||
|
for (const button2 of row) {
|
||||||
|
if (button2 == button)
|
||||||
|
button2.dataset.state = Space.SpecialInactive1.toString();
|
||||||
|
else if (button2.dataset.state == Space.SpecialInactive1.toString())
|
||||||
|
button2.dataset.state = Space.Ink1.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
button.dataset.state = Space.Empty.toString();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
button.dataset.state = Space.Empty.toString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastGridButton = button;
|
||||||
|
|
||||||
|
updateCustomCardSize();
|
||||||
|
});
|
||||||
|
row.push(button);
|
||||||
|
}
|
||||||
|
galleryCardEditorGridButtons.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryCardEditorGrid = document.getElementById('galleryCardEditorGrid')!;
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
galleryCardEditorGrid.appendChild(galleryCardEditorGridButtons[x][y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the saved checklist and custom cards.
|
||||||
|
const checklistString = localStorage.getItem('checklist');
|
||||||
|
if (checklistString) {
|
||||||
|
const cards = JSON.parse(checklistString);
|
||||||
|
Object.assign(ownedCards, cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customCardsString = localStorage.getItem('customCards');
|
||||||
|
if (customCardsString) {
|
||||||
|
for (const cardJson of JSON.parse(customCardsString)) {
|
||||||
|
cardDatabase.customCards.push(Card.fromJson(cardJson));
|
||||||
|
}
|
||||||
|
cardDatabase.customCardsModified = cardDatabase.customCards.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
const n = i < 5 ? i + 6 : i - 4;
|
||||||
|
button.dataset.value = n.toString();
|
||||||
|
galleryCardEditorSpecialCost.appendChild(button);
|
||||||
|
galleryCardEditorSpecialCostButtons.push(button);
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
customCardSpecialCost = n;
|
||||||
|
galleryCardEditorSpecialCostDefaultBox.checked = false;
|
||||||
|
updateCustomCardSpecialCost();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCustomCardSize() {
|
||||||
|
let size = 0, hasSpecialSpace = false;
|
||||||
|
for (const row of galleryCardEditorGridButtons) {
|
||||||
|
for (const button2 of row) {
|
||||||
|
switch (parseInt(button2.dataset.state!)) {
|
||||||
|
case Space.Ink1:
|
||||||
|
size++;
|
||||||
|
break;
|
||||||
|
case Space.SpecialInactive1:
|
||||||
|
size++;
|
||||||
|
hasSpecialSpace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customCardSize = size;
|
||||||
|
galleryCardDisplay!.setSize(size);
|
||||||
|
if (galleryCardEditorSpecialCostDefaultBox.checked) {
|
||||||
|
customCardSpecialCost =
|
||||||
|
size <= 3 ? 1
|
||||||
|
: size <= 5 ? 2
|
||||||
|
: size <= 8 ? 3
|
||||||
|
: size <= 11 ? 4
|
||||||
|
: size <= 15 ? 5
|
||||||
|
: 6;
|
||||||
|
if (!hasSpecialSpace && customCardSpecialCost > 3)
|
||||||
|
customCardSpecialCost = 3;
|
||||||
|
updateCustomCardSpecialCost();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCustomCardSpecialCost() {
|
||||||
|
galleryCardDisplay?.setSpecialCost(customCardSpecialCost);
|
||||||
|
for (let i = 0; i < galleryCardEditorSpecialCostButtons.length; i++) {
|
||||||
|
const button = galleryCardEditorSpecialCostButtons[i];
|
||||||
|
if (parseInt(button.dataset.value!) <= customCardSpecialCost)
|
||||||
|
button.classList.add('active');
|
||||||
|
else
|
||||||
|
button.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryCardEditorImageSelectButton.addEventListener('click', () => galleryCardEditorImageFile.click());
|
||||||
|
|
||||||
|
galleryCardEditorImageFile.addEventListener('change', async () => {
|
||||||
|
if (galleryCardEditorImageFile.files?.length != 1) return;
|
||||||
|
const originalImage = await createImageBitmap(galleryCardEditorImageFile.files[0]);
|
||||||
|
var blob = <Blob> galleryCardEditorImageFile.files[0];
|
||||||
|
if (originalImage.width > 635 || originalImage.height > 885) {
|
||||||
|
// The entire image will be stored in local storage as a data URI, so downscale larger images.
|
||||||
|
var width = originalImage.width, height = originalImage.height;
|
||||||
|
const ratio1 = 635 / width, ratio2 = 885 / height;
|
||||||
|
if (ratio1 < ratio2) {
|
||||||
|
width = 635;
|
||||||
|
height *= ratio1;
|
||||||
|
} else {
|
||||||
|
height = 885;
|
||||||
|
width *= ratio2;
|
||||||
|
}
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.drawImage(originalImage, 0, 0, width, height);
|
||||||
|
blob = await canvas.convertToBlob({ type: 'image/webp' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load image data from the original file or rescaled blob and store it in a data URI.
|
||||||
|
const url = await new Promise<string>((resolve, _) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(<string> reader.result);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const display = galleryCardDisplay!;
|
||||||
|
var image = (<SVGImageElement | undefined> display.element.getElementsByClassName('cardArt')[0]);
|
||||||
|
if (!image) {
|
||||||
|
const grid = display.svg.getElementsByClassName('cardGrid')[0];
|
||||||
|
|
||||||
|
image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
|
image.setAttribute('class', 'cardArt');
|
||||||
|
image.setAttribute('width', '100%');
|
||||||
|
image.setAttribute('height', '100%');
|
||||||
|
display.svg.insertBefore(image, grid);
|
||||||
|
}
|
||||||
|
image.setAttribute('href', url);
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorImageClearButton.addEventListener('click', async () => {
|
||||||
|
const display = galleryCardDisplay!;
|
||||||
|
const image = <SVGImageElement | undefined> display.svg.getElementsByClassName('cardArt')[0];
|
||||||
|
if (image) display.svg.removeChild(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
galleryCardEditorRarityBox.addEventListener('change', () => {
|
||||||
|
const display = galleryCardDisplay!;
|
||||||
|
display.element.classList.remove('common');
|
||||||
|
display.element.classList.remove('rare');
|
||||||
|
display.element.classList.remove('fresh');
|
||||||
|
display.element.classList.add(Rarity[parseInt(galleryCardEditorRarityBox.value)].toLowerCase());
|
||||||
|
|
||||||
|
const sizeImage = <SVGImageElement> display.svg.getElementsByClassName('cardSizeBackground')[0];
|
||||||
|
sizeImage.setAttribute('href', `assets/external/CardCost_0${galleryCardEditorRarityBox.value}.webp`);
|
||||||
|
|
||||||
|
const backgroundImage = <SVGImageElement> display.svg.getElementsByClassName('cardDisplayBackground')[0];
|
||||||
|
backgroundImage.setAttribute('href', `assets/external/CardBackground-custom-${galleryCardEditorRarityBox.value}-1.webp`);
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorColour1.addEventListener('change', galleryCardEditorColour_change);
|
||||||
|
galleryCardEditorColour2.addEventListener('change', galleryCardEditorColour_change);
|
||||||
|
|
||||||
|
function galleryCardEditorColour_change() {
|
||||||
|
const display = galleryCardDisplay!;
|
||||||
|
const filter = display.svg.getElementsByClassName('inkFilter')[0];
|
||||||
|
const selectedColours = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const value = [galleryCardEditorColour1, galleryCardEditorColour2][i].value;
|
||||||
|
const colour = { r: parseInt(value.substring(1, 3), 16), g: parseInt(value.substring(3, 5), 16), b: parseInt(value.substring(5, 7), 16) };
|
||||||
|
selectedColours.push(colour);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r1 = selectedColours[0].r / 255;
|
||||||
|
const g1 = selectedColours[0].g / 255;
|
||||||
|
const b1 = selectedColours[0].b / 255;
|
||||||
|
const dr = (selectedColours[1].r - selectedColours[0].r) / 255;
|
||||||
|
const dg = (selectedColours[1].g - selectedColours[0].g) / 255;
|
||||||
|
const db = (selectedColours[1].b - selectedColours[0].b) / 255;
|
||||||
|
filter.getElementsByTagName('feColorMatrix')[0].setAttribute('values', `${dr} 0 0 0 ${r1} 0 ${dg} 0 0 ${g1} 0 0 ${db} 0 ${b1} 0 0 0 0.88 0`);
|
||||||
|
|
||||||
|
updateSelectedPreset(selectedColours);
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryCardEditorColourPresetBox.addEventListener('change', () => {
|
||||||
|
const preset = colourPresets[galleryCardEditorColourPresetBox.value];
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
galleryCardEditorColour1.value = `#${preset[0].r.toString(16).padStart(2, '0')}${preset[0].g.toString(16).padStart(2, '0')}${preset[0].b.toString(16).padStart(2, '0')}`;
|
||||||
|
galleryCardEditorColour2.value = `#${preset[1].r.toString(16).padStart(2, '0')}${preset[1].g.toString(16).padStart(2, '0')}${preset[1].b.toString(16).padStart(2, '0')}`;
|
||||||
|
galleryCardEditorColour_change();
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorSpecialCostDefaultBox.addEventListener('change', () => {
|
||||||
|
if (galleryCardEditorSpecialCostDefaultBox.checked)
|
||||||
|
updateCustomCardSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorEditButton.addEventListener('click', () => startEditingCustomCard());
|
||||||
|
|
||||||
|
galleryNewCustomCardButton.addEventListener('click', () => {
|
||||||
|
const card = new Card(UNSAVED_CUSTOM_CARD_INDEX, 'New card', 'New card', null, Card.DEFAULT_INK_COLOUR_1, Card.DEFAULT_INK_COLOUR_2, Rarity.Common, 1, Array.from({ length: 8 }, () => [ 0, 0, 0, 0, 0, 0, 0, 0]) );
|
||||||
|
openGalleryCardView(card);
|
||||||
|
startEditingCustomCard();
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorSubmitButton.addEventListener('click', () => {
|
||||||
|
function parseColour(value: string) { return { r: parseInt(value.substring(1, 3), 16), g: parseInt(value.substring(3, 5), 16), b: parseInt(value.substring(5, 7), 16) }; }
|
||||||
|
|
||||||
|
const isNew = galleryCardDisplay!.card.number == UNSAVED_CUSTOM_CARD_INDEX;
|
||||||
|
const number = isNew ? CUSTOM_CARD_START - cardDatabase.customCards.length : galleryCardDisplay!.card.number;
|
||||||
|
const lines = Card.wrapName(galleryCardEditorName.value);
|
||||||
|
const card = new Card(number, galleryCardEditorName.value.replaceAll('\n', ' '), lines[0], lines[1], parseColour(galleryCardEditorColour1.value), parseColour(galleryCardEditorColour2.value),
|
||||||
|
<Rarity> parseInt(galleryCardEditorRarityBox.value), customCardSpecialCost, Array.from(galleryCardEditorGridButtons, r => Array.from(r, b => parseInt(b.dataset.state!))));
|
||||||
|
|
||||||
|
const image = <SVGImageElement | undefined> galleryCardDisplay!.svg.getElementsByClassName('cardArt')[0];
|
||||||
|
if (image) card.imageUrl = image.href.baseVal;
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
cardDatabase.customCards.push(card);
|
||||||
|
addCardToGallery(card);
|
||||||
|
} else {
|
||||||
|
cardDatabase.customCards[CUSTOM_CARD_START - number] = card;
|
||||||
|
updateCardInGallery(card);
|
||||||
|
}
|
||||||
|
cardDatabase.customCardsModified = true;
|
||||||
|
saveCustomCards();
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorDeleteButton.addEventListener('click', () => {
|
||||||
|
const label = galleryCardDeleteDialog.firstElementChild as HTMLElement;
|
||||||
|
label.innerText = `Are you sure you want to delete the custom card ${galleryCardDisplay!.card.name}?\nThis cannot be undone!`;
|
||||||
|
galleryCardDeleteDialog.showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorDeleteYesButton.addEventListener('click', () => {
|
||||||
|
const card = galleryCardDisplay!.card;
|
||||||
|
galleryCardList.remove(card);
|
||||||
|
galleryCardDialog.close();
|
||||||
|
|
||||||
|
let i = cardDatabase.customCards.indexOf(card);
|
||||||
|
if (i < 0) return;
|
||||||
|
|
||||||
|
// Remove the card from decks and update other custom card numbers.
|
||||||
|
for (const deck of decks) {
|
||||||
|
for (let i = 0; i < deck.cards.length; i++) {
|
||||||
|
if (deck.cards[i] == card.number)
|
||||||
|
deck.cards[i] = 0;
|
||||||
|
else if (deck.cards[i] < card.number)
|
||||||
|
deck.cards[i]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the custom cards list.
|
||||||
|
cardDatabase.customCards.splice(i, 1);
|
||||||
|
for (; i < cardDatabase.customCards.length; i++)
|
||||||
|
cardDatabase.customCards[i].number++;
|
||||||
|
|
||||||
|
saveCustomCards();
|
||||||
|
saveDecks();
|
||||||
|
});
|
||||||
|
|
||||||
|
galleryCardEditorSnapshotButton.addEventListener('click', async () => {
|
||||||
|
//const canvas = new OffscreenCanvas(635, 885);
|
||||||
|
const card = galleryCardDisplay!.card;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.id = 'cardSnapshotCanvas';
|
||||||
|
canvas.width = 442; canvas.height = 616; //canvas.setAttribute('style', 'position: absolute; left: 0; top: 0; width: 100%;');
|
||||||
|
//galleryCardDisplay!.svg.parentElement!.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
function toPx(length: SVGLength, referenceLength: number) {
|
||||||
|
switch (length.unitType) {
|
||||||
|
case SVGLength.SVG_LENGTHTYPE_NUMBER:
|
||||||
|
case SVGLength.SVG_LENGTHTYPE_PX:
|
||||||
|
return length.valueInSpecifiedUnits;
|
||||||
|
case SVGLength.SVG_LENGTHTYPE_PERCENTAGE:
|
||||||
|
return length.valueInSpecifiedUnits * referenceLength / 100;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown unit type: ${length.unitType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawElements(parent: SVGElement) {
|
||||||
|
for (const node of parent.childNodes) {
|
||||||
|
if (!(node instanceof SVGGraphicsElement)) continue;
|
||||||
|
const el = <SVGGraphicsElement> node;
|
||||||
|
|
||||||
|
if (el.transform.baseVal.length > 0) {
|
||||||
|
const transformAboutCentre = el.getAttribute('transform-origin') == 'center';
|
||||||
|
if (transformAboutCentre) ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
for (const transformElement of el.transform.baseVal) {
|
||||||
|
const matrix = transformElement.matrix;
|
||||||
|
ctx.transform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
|
||||||
|
}
|
||||||
|
if (transformAboutCentre) ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el instanceof SVGGElement) {
|
||||||
|
drawElements(el);
|
||||||
|
} else if (el instanceof SVGImageElement) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.filter = el.getAttribute('filter') ?? 'none';
|
||||||
|
if (el.hasAttribute('clip-path')) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(canvas.width * 19 / 442, canvas.height * 20 / 616, canvas.width * 404 / 442, canvas.height * 576 / 616, canvas.width * 18 / 442);
|
||||||
|
ctx.clip();
|
||||||
|
}
|
||||||
|
ctx.drawImage(<SVGImageElement> el, el.x.baseVal.value, el.y.baseVal.value, toPx(el.width.baseVal, canvas.width), toPx(el.height.baseVal, canvas.height));
|
||||||
|
ctx.filter = 'none';
|
||||||
|
ctx.restore();
|
||||||
|
} else if (el instanceof SVGRectElement) {
|
||||||
|
if (el.classList[0] == 'empty') {
|
||||||
|
ctx.fillStyle = '#00000080';
|
||||||
|
ctx.strokeStyle = '#60606080';
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.strokeRect(toPx(el.x.baseVal, canvas.width), toPx(el.y.baseVal, canvas.height), toPx(el.width.baseVal, canvas.width), toPx(el.height.baseVal, canvas.height));
|
||||||
|
} else if (el.classList[0] == 'ink') {
|
||||||
|
ctx.fillStyle = document.body.style.getPropertyValue('--primary-colour-1');
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = document.body.style.getPropertyValue('--special-colour-1');
|
||||||
|
}
|
||||||
|
ctx.fillRect(toPx(el.x.baseVal, canvas.width), toPx(el.y.baseVal, canvas.height), toPx(el.width.baseVal, canvas.width), toPx(el.height.baseVal, canvas.height));
|
||||||
|
} else if (el instanceof SVGTextElement) {
|
||||||
|
const text = <SVGTextElement> el;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
switch (text.classList.contains('cardDisplayName') ? card.rarity : Rarity.Common) {
|
||||||
|
case Rarity.Rare:
|
||||||
|
const rareGradient = ctx.createLinearGradient(canvas.width * 0.029, canvas.height * 0.273, canvas.width * 1.081, canvas.height * 0.041);
|
||||||
|
rareGradient.addColorStop(0, '#E0AE12');
|
||||||
|
rareGradient.addColorStop(0.25, '#FBFFCC');
|
||||||
|
rareGradient.addColorStop(0.5, '#E0AE12');
|
||||||
|
rareGradient.addColorStop(0.75, '#FBFFCC');
|
||||||
|
rareGradient.addColorStop(1, '#E0AE12');
|
||||||
|
ctx.fillStyle = rareGradient;
|
||||||
|
break;
|
||||||
|
case Rarity.Fresh:
|
||||||
|
const freshGradient = ctx.createLinearGradient(canvas.width * (0.5 - 0.325 / card.textScale), canvas.height * -0.025, canvas.width * (0.5 + 0.335 / card.textScale), canvas.height * 0.32);
|
||||||
|
freshGradient.addColorStop(0, '#FF93DD');
|
||||||
|
freshGradient.addColorStop(0.2, '#FEF499');
|
||||||
|
freshGradient.addColorStop(0.5, '#C9448A');
|
||||||
|
freshGradient.addColorStop(0.75, '#1EFBC3');
|
||||||
|
freshGradient.addColorStop(0.95, '#FD97DB');
|
||||||
|
freshGradient.addColorStop(1, '#FFBAC2');
|
||||||
|
ctx.fillStyle = freshGradient;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ctx.fillStyle = text.getAttribute('fill')!;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = text.getAttribute('stroke')!;
|
||||||
|
ctx.lineWidth = parseFloat(text.getAttribute('stroke-width')!);
|
||||||
|
ctx.font = `bold ${text.getAttribute('font-size')!}px 'Splatoon 1'`;
|
||||||
|
(<any> ctx).wordSpacing = `${text.getAttribute('word-spacing') ?? '0'}px`;
|
||||||
|
for (const node2 of text.childNodes) {
|
||||||
|
const textContent = node2.textContent!;
|
||||||
|
if (node2 instanceof CharacterData) {
|
||||||
|
ctx.strokeText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(text.y.baseVal[0], canvas.height));
|
||||||
|
ctx.fillText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(text.y.baseVal[0], canvas.height));
|
||||||
|
} else if (node2 instanceof SVGTSpanElement) {
|
||||||
|
const tspan = <SVGTSpanElement> node2;
|
||||||
|
ctx.strokeText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(tspan.y.baseVal[0], canvas.height));
|
||||||
|
ctx.fillText(textContent, toPx(text.x.baseVal[0], canvas.width), toPx(tspan.y.baseVal[0], canvas.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.transform.baseVal.length > 0) ctx.resetTransform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawElements(galleryCardDisplay!.svg);
|
||||||
|
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
if (blob == null) {
|
||||||
|
alert('Could not create the image.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'card.png';
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -47,7 +47,7 @@ playContainers.sort((a, b) => parseInt(a.dataset.index || '0') - parseInt(b.data
|
||||||
|
|
||||||
const testControls = document.getElementById('testControls')!;
|
const testControls = document.getElementById('testControls')!;
|
||||||
const testDeckList = document.getElementById('testDeckList')!;
|
const testDeckList = document.getElementById('testDeckList')!;
|
||||||
const testAllCardsList = CardList.fromId('testAllCardsList', 'testAllCardsListSortBox', 'testAllCardsListFilterBox');
|
const testAllCardsList = CardList.fromId<CardButton>('testAllCardsList', 'testAllCardsListSortBox', 'testAllCardsListFilterBox');
|
||||||
const testPlacementList = document.getElementById('testPlacementList')!;
|
const testPlacementList = document.getElementById('testPlacementList')!;
|
||||||
const testDeckButton = CheckButton.fromId('testDeckButton');
|
const testDeckButton = CheckButton.fromId('testDeckButton');
|
||||||
const testDeckContainer = document.getElementById('testDeckContainer')!;
|
const testDeckContainer = document.getElementById('testDeckContainer')!;
|
||||||
|
|
@ -59,6 +59,7 @@ const testCardButtonGroup = new CheckButtonGroup<Card>();
|
||||||
const testDeckCardButtons: CardButton[] = [ ];
|
const testDeckCardButtons: CardButton[] = [ ];
|
||||||
const testPlacements: { card: Card, placementResults: PlacementResults }[] = [ ];
|
const testPlacements: { card: Card, placementResults: PlacementResults }[] = [ ];
|
||||||
const testCardListBackdrop = document.getElementById('testCardListBackdrop')!;
|
const testCardListBackdrop = document.getElementById('testCardListBackdrop')!;
|
||||||
|
let testPlacementButtonClicked = false;
|
||||||
|
|
||||||
let playHintHtml: string | null = null;
|
let playHintHtml: string | null = null;
|
||||||
|
|
||||||
|
|
@ -122,6 +123,8 @@ function initSpectator() {
|
||||||
spectatorRow.hidden = false;
|
spectatorRow.hidden = false;
|
||||||
flipButton.hidden = false;
|
flipButton.hidden = false;
|
||||||
gameButtonsContainer.hidden = false;
|
gameButtonsContainer.hidden = false;
|
||||||
|
board.autoHighlight = false;
|
||||||
|
board.flip = false;
|
||||||
showPage('game');
|
showPage('game');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +142,7 @@ function initReplay() {
|
||||||
flipButton.hidden = false;
|
flipButton.hidden = false;
|
||||||
gameButtonsContainer.hidden = false;
|
gameButtonsContainer.hidden = false;
|
||||||
gamePage.dataset.myPlayerIndex = '0';
|
gamePage.dataset.myPlayerIndex = '0';
|
||||||
|
board.flip = false;
|
||||||
updateColours();
|
updateColours();
|
||||||
gamePage.dataset.uiBaseColourIsSpecialColour = (userConfig.colourLock || (currentGame!.game.players[0].uiBaseColourIsSpecialColour ?? true)).toString();
|
gamePage.dataset.uiBaseColourIsSpecialColour = (userConfig.colourLock || (currentGame!.game.players[0].uiBaseColourIsSpecialColour ?? true)).toString();
|
||||||
canPlay = false;
|
canPlay = false;
|
||||||
|
|
@ -154,7 +158,8 @@ function initTest(stage: Stage) {
|
||||||
clear();
|
clear();
|
||||||
testMode = true;
|
testMode = true;
|
||||||
gamePage.classList.add('deckTest');
|
gamePage.classList.add('deckTest');
|
||||||
currentGame = { id: 'test', game: { state: GameState.Ongoing, maxPlayers: 2, players: [ ], turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ] }, webSocket: null };
|
currentGame = { id: 'test', game: { state: GameState.Ongoing, maxPlayers: 2, players: [ ], turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null, allowUpcomingCards: true, allowCustomCards: true }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ], stageSelectionPrompt: null }, isHost: false, webSocket: null };
|
||||||
|
board.flip = false;
|
||||||
board.resize(stage.copyGrid());
|
board.resize(stage.copyGrid());
|
||||||
const startSpaces = stage.getStartSpaces(2);
|
const startSpaces = stage.getStartSpaces(2);
|
||||||
board.startSpaces = startSpaces;
|
board.startSpaces = startSpaces;
|
||||||
|
|
@ -169,8 +174,7 @@ function initTest(stage: Stage) {
|
||||||
testPlacements.splice(0);
|
testPlacements.splice(0);
|
||||||
testUndoButton.enabled = false;
|
testUndoButton.enabled = false;
|
||||||
clearChildren(testPlacementList);
|
clearChildren(testPlacementList);
|
||||||
gamePage.dataset.myPlayerIndex = '0';
|
|
||||||
gamePage.dataset.uiBaseColourIsSpecialColour = uiBaseColourIsSpecialColourOutOfGame.toString();
|
|
||||||
gameButtonsContainer.hidden = false;
|
gameButtonsContainer.hidden = false;
|
||||||
testControls.hidden = false;
|
testControls.hidden = false;
|
||||||
clearPlayContainers();
|
clearPlayContainers();
|
||||||
|
|
@ -186,6 +190,27 @@ function initTest(stage: Stage) {
|
||||||
button.enabled = true;
|
button.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function swapColours() {
|
||||||
|
// Swap colours to preserve the player's ink colour.
|
||||||
|
const oldPlayerIndex = parseInt(gamePage.dataset.myPlayerIndex ?? '0');
|
||||||
|
if (oldPlayerIndex) {
|
||||||
|
swapColour('primary', oldPlayerIndex);
|
||||||
|
swapColour('special', oldPlayerIndex);
|
||||||
|
swapColour('special-accent', oldPlayerIndex);
|
||||||
|
const temp = uiBaseColourIsSpecialColourPerPlayer[0];
|
||||||
|
uiBaseColourIsSpecialColourPerPlayer[0] = uiBaseColourIsSpecialColourPerPlayer[oldPlayerIndex];
|
||||||
|
uiBaseColourIsSpecialColourPerPlayer[oldPlayerIndex] = temp;
|
||||||
|
}
|
||||||
|
gamePage.dataset.myPlayerIndex = '0';
|
||||||
|
uiBaseColourIsSpecialColourOutOfGame = uiBaseColourIsSpecialColourPerPlayer[0];
|
||||||
|
gamePage.dataset.uiBaseColourIsSpecialColour = uiBaseColourIsSpecialColourOutOfGame.toString();
|
||||||
|
}
|
||||||
|
function swapColour(prefix: string, oldPlayerIndex: number) {
|
||||||
|
const temp = document.body.style.getPropertyValue(`--${prefix}-colour-1`);
|
||||||
|
document.body.style.setProperty(`--${prefix}-colour-1`, document.body.style.getPropertyValue(`--${prefix}-colour-${oldPlayerIndex + 1}`));
|
||||||
|
document.body.style.setProperty(`--${prefix}-colour-${oldPlayerIndex + 1}`, temp);
|
||||||
|
}
|
||||||
|
|
||||||
replayNextButton.buttonElement.addEventListener('click', _ => {
|
replayNextButton.buttonElement.addEventListener('click', _ => {
|
||||||
if (currentGame == null || currentReplay == null || currentGame.game.state == GameState.GameEnded || currentGame.game.state == GameState.SetEnded)
|
if (currentGame == null || currentReplay == null || currentGame.game.state == GameState.GameEnded || currentGame.game.state == GameState.SetEnded)
|
||||||
return;
|
return;
|
||||||
|
|
@ -236,7 +261,9 @@ replayNextButton.buttonElement.addEventListener('click', _ => {
|
||||||
|
|
||||||
replayAnimationAbortController = new AbortController();
|
replayAnimationAbortController = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.game.turnNumber, players: currentGame.game.players }, moves, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, replayAnimationAbortController.signal);
|
const abortSignal = replayAnimationAbortController.signal;
|
||||||
|
await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.game.turnNumber, players: currentGame.game.players }, moves, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, abortSignal);
|
||||||
|
if (abortSignal.aborted) return;
|
||||||
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
|
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
|
||||||
clearPlayContainers();
|
clearPlayContainers();
|
||||||
if (currentGame.game.turnNumber > 12) {
|
if (currentGame.game.turnNumber > 12) {
|
||||||
|
|
@ -451,9 +478,35 @@ function testCardButton_click(button: CardButton) {
|
||||||
board.table.focus();
|
board.table.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testPlacementListItem_click(button: HTMLButtonElement, placement: Placement) {
|
||||||
|
testPlacementButtonClicked = true;
|
||||||
|
const highlight = button.classList.toggle('testHighlight');
|
||||||
|
for (const p of placement.spacesAffected)
|
||||||
|
board.setTestHighlight(p.space.x, p.space.y, highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPlacementListItem_pointerenter(button: HTMLButtonElement, placement: Placement) {
|
||||||
|
testPlacementButtonClicked = false;
|
||||||
|
if (button.classList.contains('testHighlight')) return;
|
||||||
|
for (const p of placement.spacesAffected)
|
||||||
|
board.setTestHighlight(p.space.x, p.space.y, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPlacementListItem_pointerleave(button: HTMLButtonElement, placement: Placement) {
|
||||||
|
if (testPlacementButtonClicked) return;
|
||||||
|
if (button.classList.contains('testHighlight')) return;
|
||||||
|
for (const p of placement.spacesAffected)
|
||||||
|
board.setTestHighlight(p.space.x, p.space.y, false);
|
||||||
|
}
|
||||||
|
|
||||||
testUndoButton.buttonElement.addEventListener('click', () => {
|
testUndoButton.buttonElement.addEventListener('click', () => {
|
||||||
const turn = testPlacements.pop();
|
const turn = testPlacements.pop();
|
||||||
if (turn) {
|
if (turn) {
|
||||||
|
// Remove the highlight if needed.
|
||||||
|
for (const p of turn.placementResults.placements[0].spacesAffected)
|
||||||
|
board.setTestHighlight(p.space.x, p.space.y, false);
|
||||||
|
|
||||||
|
// Undo the placement.
|
||||||
undoTurn(turn.placementResults);
|
undoTurn(turn.placementResults);
|
||||||
testPlacementList.removeChild(testPlacementList.firstChild!);
|
testPlacementList.removeChild(testPlacementList.firstChild!);
|
||||||
|
|
||||||
|
|
@ -468,6 +521,7 @@ testUndoButton.buttonElement.addEventListener('click', () => {
|
||||||
|
|
||||||
testBackButton.addEventListener('click', _ => {
|
testBackButton.addEventListener('click', _ => {
|
||||||
showPage(editingDeck ? 'deckEdit' : 'deckList');
|
showPage(editingDeck ? 'deckEdit' : 'deckList');
|
||||||
|
board.clearTestHighlight();
|
||||||
});
|
});
|
||||||
|
|
||||||
testDeckButton.buttonElement.addEventListener('click', _ => {
|
testDeckButton.buttonElement.addEventListener('click', _ => {
|
||||||
|
|
@ -498,6 +552,7 @@ function loadPlayers(players: Player[]) {
|
||||||
const player = players[i];
|
const player = players[i];
|
||||||
currentGame!.game.players[i] = players[i];
|
currentGame!.game.players[i] = players[i];
|
||||||
playerBars[i].name = player.name;
|
playerBars[i].name = player.name;
|
||||||
|
playerBars[i].setOnline(player.isOnline);
|
||||||
playerBars[i].winCounter.wins = players[i].gamesWon;
|
playerBars[i].winCounter.wins = players[i].gamesWon;
|
||||||
updateStats(i, scores);
|
updateStats(i, scores);
|
||||||
}
|
}
|
||||||
|
|
@ -509,7 +564,7 @@ function loadPlayers(players: Player[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateColours() {
|
function updateColours() {
|
||||||
if (currentGame == null) return;
|
if (currentGame == null || currentGame.game.players.length == 0) return;
|
||||||
for (let i = 0; i < currentGame.game.players.length; i++) {
|
for (let i = 0; i < currentGame.game.players.length; i++) {
|
||||||
if (currentGame.game.players[i].colour.r > 0 || currentGame.game.players[i].colour.g > 0 || currentGame.game.players[i].colour.b > 0) {
|
if (currentGame.game.players[i].colour.r > 0 || currentGame.game.players[i].colour.g > 0 || currentGame.game.players[i].colour.b > 0) {
|
||||||
setColour(i, 0, currentGame.game.players[i].colour);
|
setColour(i, 0, currentGame.game.players[i].colour);
|
||||||
|
|
@ -519,9 +574,10 @@ function updateColours() {
|
||||||
updateHSL(i, j);
|
updateHSL(i, j);
|
||||||
updateRGB(i, j);
|
updateRGB(i, j);
|
||||||
}
|
}
|
||||||
|
uiBaseColourIsSpecialColourPerPlayer[i] = currentGame.game.players[i].uiBaseColourIsSpecialColour;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uiBaseColourIsSpecialColourOutOfGame = currentGame.game.players[0].uiBaseColourIsSpecialColour ?? true;
|
uiBaseColourIsSpecialColourOutOfGame = uiBaseColourIsSpecialColourPerPlayer[currentGame?.me?.playerIndex ?? 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats(playerIndex: number, scores: number[]) {
|
function updateStats(playerIndex: number, scores: number[]) {
|
||||||
|
|
@ -794,13 +850,12 @@ function populateShowDeck(deck: Deck) {
|
||||||
|
|
||||||
/** Handles an update to the player's hand and/or deck during a game. */
|
/** Handles an update to the player's hand and/or deck during a game. */
|
||||||
function updateHandAndDeck(playerData: PlayerData) {
|
function updateHandAndDeck(playerData: PlayerData) {
|
||||||
handButtons.clear();
|
const hand = playerData.hand!;
|
||||||
|
|
||||||
populateShowDeck(playerData.deck!);
|
populateShowDeck(playerData.deck!);
|
||||||
|
|
||||||
for (const button of showDeckButtons) {
|
for (const button of showDeckButtons) {
|
||||||
const li = button.buttonElement.parentElement!;
|
const li = button.buttonElement.parentElement!;
|
||||||
if (playerData.hand!.find(c => c.number == button.card.number))
|
if (hand.find(c => c.number == button.card.number))
|
||||||
li.className = 'inHand';
|
li.className = 'inHand';
|
||||||
else if (playerData.cardsUsed.includes(button.card.number))
|
else if (playerData.cardsUsed.includes(button.card.number))
|
||||||
li.className = 'used';
|
li.className = 'used';
|
||||||
|
|
@ -809,7 +864,20 @@ function updateHandAndDeck(playerData: PlayerData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentGame?.me) return;
|
if (!currentGame?.me) return;
|
||||||
currentGame.me.hand = playerData.hand!.map(Card.fromJson);
|
|
||||||
|
if (handButtons.entries.length == 4 && hand.length == 4) {
|
||||||
|
let handIsSame = true;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if ((<CardButton> handButtons.entries[i].button).card.number != hand[i].number) {
|
||||||
|
handIsSame = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handIsSame) return; // The player's hand has not changed after reconnecting to the game.
|
||||||
|
}
|
||||||
|
currentGame.me.hand = hand.map(Card.fromJson);
|
||||||
|
handButtons.clear();
|
||||||
|
board.autoHighlight = false;
|
||||||
for (let i = 0; i < currentGame.me.hand.length; i++) {
|
for (let i = 0; i < currentGame.me.hand.length; i++) {
|
||||||
const card = currentGame.me.hand[i];
|
const card = currentGame.me.hand[i];
|
||||||
const button = new CardButton(card);
|
const button = new CardButton(card);
|
||||||
|
|
@ -1031,12 +1099,12 @@ board.onsubmit = (x, y) => {
|
||||||
if (result.specialSpacesActivated.length > 0)
|
if (result.specialSpacesActivated.length > 0)
|
||||||
setTimeout(() => board.refresh(), 333);
|
setTimeout(() => board.refresh(), 333);
|
||||||
|
|
||||||
var li = document.createElement('div');
|
var li = document.createElement('button');
|
||||||
li.innerText = board.cardPlaying.name;
|
li.innerText = board.cardPlaying.name;
|
||||||
if (testDeckCardButtons.find(b => b.card.number == board.cardPlaying!.number))
|
li.classList.add(testDeckCardButtons.find(b => b.card.number == board.cardPlaying!.number) ? 'deckCard' : 'externalCard');
|
||||||
li.classList.add('deckCard');
|
li.addEventListener('click', () => testPlacementListItem_click(li, result.placements[0]));
|
||||||
else
|
li.addEventListener('pointerenter', () => testPlacementListItem_pointerenter(li, result.placements[0]));
|
||||||
li.classList.add('externalCard');
|
li.addEventListener('pointerleave', () => testPlacementListItem_pointerleave(li, result.placements[0]));
|
||||||
testPlacementList.insertBefore(li, testPlacementList.firstChild);
|
testPlacementList.insertBefore(li, testPlacementList.firstChild);
|
||||||
|
|
||||||
for (const button of testDeckCardButtons.concat(testAllCardsList.cardButtons)) {
|
for (const button of testDeckCardButtons.concat(testAllCardsList.cardButtons)) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
|
const playerList = document.getElementById('playerList')!;
|
||||||
|
const playerListSlots: HTMLElement[] = [ ];
|
||||||
|
const playerListNames: HTMLElement[] = [ ];
|
||||||
const lobbyWinCounters: WinCounter[] = [ ];
|
const lobbyWinCounters: WinCounter[] = [ ];
|
||||||
|
const playerListItemsToRemove: HTMLElement[] = [ ];
|
||||||
|
let playerListItemToRemove: HTMLElement | null = null;
|
||||||
|
|
||||||
const stageButtons = new CheckButtonGroup<Stage>(document.getElementById('stageList')!);
|
const stageButtons = new CheckButtonGroup<number>(document.getElementById('stageList')!);
|
||||||
const shareLinkButton = document.getElementById('shareLinkButton') as HTMLButtonElement;
|
const shareLinkButton = document.getElementById('shareLinkButton') as HTMLButtonElement;
|
||||||
const showQrCodeButton = document.getElementById('showQrCodeButton') as HTMLButtonElement;
|
const showQrCodeButton = document.getElementById('showQrCodeButton') as HTMLButtonElement;
|
||||||
const stageSelectionForm = document.getElementById('stageSelectionForm') as HTMLFormElement;
|
const stageSelectionForm = document.getElementById('stageSelectionForm') as HTMLFormElement;
|
||||||
const stageSelectionFormLoadingSection = stageSelectionForm.getElementsByClassName('loadingContainer')[0] as HTMLElement;
|
const stageSelectionFormLoadingSection = stageSelectionForm.getElementsByClassName('loadingContainer')[0] as HTMLElement;
|
||||||
const stageSelectionFormSubmitButton = document.getElementById('submitStageButton') as HTMLButtonElement;
|
|
||||||
const stageRandomButton = CheckButton.fromId('stageRandomButton');
|
const stageRandomButton = CheckButton.fromId('stageRandomButton');
|
||||||
|
const strikeOrderSelectionForm = document.getElementById('strikeOrderSelectionForm') as HTMLFormElement;
|
||||||
|
|
||||||
const deckSelectionForm = document.getElementById('deckSelectionForm') as HTMLFormElement;
|
const deckSelectionForm = document.getElementById('deckSelectionForm') as HTMLFormElement;
|
||||||
const deckSelectionFormLoadingSection = deckSelectionForm.getElementsByClassName('loadingContainer')[0] as HTMLElement;
|
const deckSelectionFormLoadingSection = deckSelectionForm.getElementsByClassName('loadingContainer')[0] as HTMLElement;
|
||||||
const lobbySelectedStageSection = document.getElementById('lobbySelectedStageSection')!;
|
const lobbySelectedStageSection = document.getElementById('lobbySelectedStageSection')!;
|
||||||
const lobbyStageSection = document.getElementById('lobbyStageSection')!;
|
const lobbyStageSection = document.getElementById('lobbyStageSection')!;
|
||||||
|
const stagePrompt = document.getElementById('stagePrompt')!;
|
||||||
const lobbyStageSubmitButton = document.getElementById('submitStageButton') as HTMLButtonElement;
|
const lobbyStageSubmitButton = document.getElementById('submitStageButton') as HTMLButtonElement;
|
||||||
const lobbyDeckSection = document.getElementById('lobbyDeckSection')!;
|
const lobbyDeckSection = document.getElementById('lobbyDeckSection')!;
|
||||||
const lobbyDeckList = document.getElementById('lobbyDeckList')!;
|
const lobbyDeckList = document.getElementById('lobbyDeckList')!;
|
||||||
|
|
@ -19,6 +25,8 @@ const lobbyDeckButtons = new CheckButtonGroup<SavedDeck>(lobbyDeckList);
|
||||||
const lobbyDeckSubmitButton = document.getElementById('submitDeckButton') as HTMLButtonElement;
|
const lobbyDeckSubmitButton = document.getElementById('submitDeckButton') as HTMLButtonElement;
|
||||||
|
|
||||||
const lobbyTimeLimitBox = document.getElementById('lobbyTimeLimitBox') as HTMLInputElement;
|
const lobbyTimeLimitBox = document.getElementById('lobbyTimeLimitBox') as HTMLInputElement;
|
||||||
|
const lobbyAllowUpcomingCardsBox = document.getElementById('lobbyAllowUpcomingCardsBox') as HTMLInputElement;
|
||||||
|
const lobbyAllowCustomCardsBox = document.getElementById('lobbyAllowCustomCardsBox') as HTMLInputElement;
|
||||||
const lobbyTimeLimitUnit = document.getElementById('lobbyTimeLimitUnit')!;
|
const lobbyTimeLimitUnit = document.getElementById('lobbyTimeLimitUnit')!;
|
||||||
|
|
||||||
const qrCodeDialog = document.getElementById('qrCodeDialog') as HTMLDialogElement;
|
const qrCodeDialog = document.getElementById('qrCodeDialog') as HTMLDialogElement;
|
||||||
|
|
@ -26,13 +34,17 @@ let qrCode: QRCode | null;
|
||||||
let lobbyShareData: ShareData | null;
|
let lobbyShareData: ShareData | null;
|
||||||
|
|
||||||
let selectedStageIndicator = null as StageButton | null;
|
let selectedStageIndicator = null as StageButton | null;
|
||||||
|
let stageSelectionPrompt = null as StageSelectionPrompt | null;
|
||||||
|
|
||||||
function lobbyInitStageDatabase(stages: Stage[]) {
|
function lobbyInitStageDatabase(stages: Stage[]) {
|
||||||
|
stageButtons.add(stageRandomButton, -1);
|
||||||
|
let i = 0;
|
||||||
for (const stage of stages) {
|
for (const stage of stages) {
|
||||||
const button = new StageButton(stage);
|
const button = new StageButton(stage);
|
||||||
stageButtons.add(button, stage);
|
stageButtons.add(button, i++);
|
||||||
button.buttonElement.addEventListener('click', () => {
|
button.buttonElement.addEventListener('click', () => {
|
||||||
stageRandomButton.checked = false;
|
stageRandomButton.checked = false;
|
||||||
|
lobbyStageSubmitButton.disabled = !stageSelectionPrompt || stageButtons.buttons.filter(b => b.checked).length != (stageSelectionPrompt.promptType == StageSelectionPromptType.Strike ? stageSelectionPrompt.numberOfStagesToStrike : 1);
|
||||||
});
|
});
|
||||||
button.setStartSpaces(2);
|
button.setStartSpaces(2);
|
||||||
}
|
}
|
||||||
|
|
@ -47,14 +59,75 @@ function initLobbyPage(url: string) {
|
||||||
lobbyShareData = null;
|
lobbyShareData = null;
|
||||||
shareLinkButton.innerText = 'Copy link';
|
shareLinkButton.innerText = 'Copy link';
|
||||||
}
|
}
|
||||||
|
lobbyDeckSection.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showStageSelectionForm() {
|
function showStageSelectionForm(prompt: StageSelectionPrompt | null, isReady: boolean) {
|
||||||
|
stageSelectionPrompt = prompt;
|
||||||
|
if (!prompt) return;
|
||||||
|
|
||||||
lobbyStageSection.hidden = false;
|
lobbyStageSection.hidden = false;
|
||||||
stageSelectionFormLoadingSection.hidden = true;
|
stageSelectionFormLoadingSection.hidden = true;
|
||||||
stageRandomButton.checked = true;
|
stageRandomButton.checked = true;
|
||||||
stageButtons.deselect();
|
stageButtons.deselect();
|
||||||
lobbyStageSubmitButton.disabled = false;
|
lobbyStageSubmitButton.disabled = true;
|
||||||
|
|
||||||
|
let i = -1;
|
||||||
|
for (const button of stageButtons.buttons) {
|
||||||
|
const originalClass = i < 0 ? 'stageRandom' : 'stage';
|
||||||
|
if (i >= 0 && (currentGame!.game.maxPlayers > stageDatabase.stages![i].maxPlayers || prompt.bannedStages?.includes(i))) {
|
||||||
|
button.buttonElement.className = `${originalClass} banned`;
|
||||||
|
button.enabled = false;
|
||||||
|
} else if (prompt.struckStages?.includes(i)) {
|
||||||
|
button.buttonElement.className = `${originalClass} struck`;
|
||||||
|
button.enabled = false;
|
||||||
|
} else {
|
||||||
|
button.buttonElement.className = originalClass;
|
||||||
|
button.enabled = prompt.promptType != StageSelectionPromptType.Wait && !isReady;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (prompt.promptType) {
|
||||||
|
case StageSelectionPromptType.Vote:
|
||||||
|
stageSelectionForm.hidden = false;
|
||||||
|
stageRandomButton.buttonElement.hidden = false;
|
||||||
|
strikeOrderSelectionForm.hidden = true;
|
||||||
|
stagePrompt.innerText = isReady ? 'Opponent is choosing...' : 'Vote for the stage.';
|
||||||
|
stageButtons.allowMultipleSelections = false;
|
||||||
|
stageButtons.parentElement!.classList.remove('striking');
|
||||||
|
break;
|
||||||
|
case StageSelectionPromptType.VoteOrder:
|
||||||
|
stageSelectionForm.hidden = true;
|
||||||
|
strikeOrderSelectionForm.hidden = false;
|
||||||
|
for (const button of strikeOrderSelectionForm.getElementsByTagName('button'))
|
||||||
|
(<HTMLButtonElement> button).disabled = isReady;
|
||||||
|
break;
|
||||||
|
case StageSelectionPromptType.Strike:
|
||||||
|
stageSelectionForm.hidden = false;
|
||||||
|
stageRandomButton.buttonElement.hidden = true;
|
||||||
|
strikeOrderSelectionForm.hidden = true;
|
||||||
|
stagePrompt.innerText = prompt.numberOfStagesToStrike == 1 ? 'Choose a stage to strike.' : `Choose ${prompt.numberOfStagesToStrike} stages to strike.`;
|
||||||
|
stageButtons.allowMultipleSelections = prompt.numberOfStagesToStrike != 1;
|
||||||
|
stageButtons.parentElement!.classList.add('striking');
|
||||||
|
break;
|
||||||
|
case StageSelectionPromptType.Choose:
|
||||||
|
stageSelectionForm.hidden = false;
|
||||||
|
stageRandomButton.buttonElement.hidden = true;
|
||||||
|
strikeOrderSelectionForm.hidden = true;
|
||||||
|
stagePrompt.innerText = 'Choose the stage for the next battle.';
|
||||||
|
stageButtons.allowMultipleSelections = false;
|
||||||
|
stageButtons.parentElement!.classList.remove('striking');
|
||||||
|
break;
|
||||||
|
case StageSelectionPromptType.Wait:
|
||||||
|
stageSelectionForm.hidden = false;
|
||||||
|
stageRandomButton.buttonElement.hidden = true;
|
||||||
|
strikeOrderSelectionForm.hidden = true;
|
||||||
|
stagePrompt.innerText = currentGame?.game.state == GameState.ChoosingStage ? 'Opponent is choosing...' : 'Possible stages:';
|
||||||
|
stageButtons.allowMultipleSelections = false;
|
||||||
|
stageButtons.parentElement!.classList.remove('striking');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shareLinkButton.addEventListener('click', () => {
|
shareLinkButton.addEventListener('click', () => {
|
||||||
|
|
@ -88,55 +161,118 @@ qrCodeDialog.addEventListener('click', e => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function lobbyResetSlots() {
|
function lobbyResetSlots() {
|
||||||
if (!currentGame) throw new Error('No current game');
|
if (!currentGame) throw new TypeError('No current game');
|
||||||
for (const li of playerListItems)
|
playerListSlots.splice(0);
|
||||||
playerList.removeChild(li);
|
playerListNames.splice(0);
|
||||||
playerListItems.splice(0);
|
|
||||||
lobbyWinCounters.splice(0);
|
lobbyWinCounters.splice(0);
|
||||||
|
clearChildren(playerList);
|
||||||
|
|
||||||
for (let i = 0; i < currentGame.game.maxPlayers; i++) {
|
for (let i = 0; i < currentGame.game.maxPlayers; i++) {
|
||||||
var el = document.createElement('li');
|
const el = document.createElement('li');
|
||||||
el.className = 'empty';
|
const placeholder = document.createElement('div');
|
||||||
el.innerText = 'Waiting...';
|
placeholder.className = 'placeholder';
|
||||||
playerListItems.push(el);
|
placeholder.innerText = 'Waiting...';
|
||||||
playerList.appendChild(el);
|
playerList.appendChild(el);
|
||||||
|
el.appendChild(placeholder);
|
||||||
|
playerListSlots.push(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
lobbyLockSettings(currentGame.me?.playerIndex != 0);
|
lobbyLockSettings(!currentGame.isHost);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lobbyLockSettings(lock: boolean) {
|
function lobbyLockSettings(lock: boolean) {
|
||||||
lobbyTimeLimitBox.readOnly = lock;
|
lobbyTimeLimitBox.readOnly = lock;
|
||||||
|
lobbyAllowUpcomingCardsBox.disabled = lock;
|
||||||
|
lobbyAllowCustomCardsBox.disabled = lock;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearReady() {
|
function clearReady() {
|
||||||
if (!currentGame) throw new Error('No current game');
|
if (!currentGame) throw new TypeError('No current game');
|
||||||
stageSelectionFormSubmitButton.disabled = false;
|
lobbyStageSubmitButton.disabled = false;
|
||||||
stageSelectionFormLoadingSection.hidden = true;
|
stageSelectionFormLoadingSection.hidden = true;
|
||||||
for (var i = 0; i < currentGame.game.players.length; i++) {
|
for (var i = 0; i < currentGame.game.players.length; i++) {
|
||||||
currentGame.game.players[i].isReady = false;
|
currentGame.game.players[i].isReady = false;
|
||||||
playerListItems[i].className = 'filled';
|
playerListNames[i].classList.remove('ready');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lobbyAddPlayer(playerIndex: number) {
|
function lobbyAddPlayer() {
|
||||||
if (!currentGame) throw new Error('No current game');
|
if (!currentGame) throw new TypeError('No current game');
|
||||||
const listItem = playerListItems[playerIndex];
|
|
||||||
|
if (playerListItemToRemove) {
|
||||||
|
playerListItemToRemove.removeEventListener('animationend', playerListItem_animationEnd);
|
||||||
|
playerListItem_animationEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerIndex = playerListNames.length;
|
||||||
|
const slot = playerListSlots[playerIndex];
|
||||||
const player = currentGame.game.players[playerIndex];
|
const player = currentGame.game.players[playerIndex];
|
||||||
listItem.innerText = player.name;
|
|
||||||
listItem.className = player.isReady ? 'filled ready' : 'filled';
|
|
||||||
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'wins';
|
el.classList.add('filled');
|
||||||
el.title = 'Battles won';
|
if (player.isReady) el.classList.add('ready');
|
||||||
listItem.appendChild(el);
|
if (!player.isOnline) el.classList.add('disconnected');
|
||||||
const winCounter = new WinCounter(el);
|
el.innerText = player.name;
|
||||||
winCounter.wins = currentGame.game.players[playerIndex].gamesWon;
|
slot.appendChild(el);
|
||||||
|
playerListNames.push(el);
|
||||||
|
|
||||||
|
const el2 = document.createElement('img');
|
||||||
|
el2.src = 'assets/wifi-off.svg';
|
||||||
|
el2.className = 'disconnectedIcon';
|
||||||
|
el2.title = 'Disconnected';
|
||||||
|
el.appendChild(el2);
|
||||||
|
|
||||||
|
const el3 = document.createElement('div');
|
||||||
|
el3.className = 'wins';
|
||||||
|
el3.title = 'Battles won';
|
||||||
|
el.appendChild(el3);
|
||||||
|
|
||||||
|
const winCounter = new WinCounter(el3);
|
||||||
|
winCounter.wins = player.gamesWon;
|
||||||
lobbyWinCounters.push(winCounter);
|
lobbyWinCounters.push(winCounter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lobbyRemovePlayer(playerIndex: number) {
|
||||||
|
if (!currentGame) throw new TypeError('No current game');
|
||||||
|
|
||||||
|
// Animate the leaving player and all entries below them to mimic the original game.
|
||||||
|
for (let i = playerIndex; i < playerListNames.length; i++)
|
||||||
|
(<HTMLElement>playerListSlots[i].lastElementChild).classList.add('removed');
|
||||||
|
|
||||||
|
const el = <HTMLElement>playerListSlots[playerIndex].lastElementChild;
|
||||||
|
el.classList.add('removed');
|
||||||
|
playerListItemsToRemove.push(el);
|
||||||
|
|
||||||
|
if (playerListItemToRemove)
|
||||||
|
playerListItemToRemove.removeEventListener('animationend', playerListItem_animationEnd);
|
||||||
|
|
||||||
|
playerListItemToRemove = el;
|
||||||
|
el.addEventListener('animationend', playerListItem_animationEnd);
|
||||||
|
playerListNames.splice(playerIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerListItem_animationEnd() {
|
||||||
|
for (const el of playerListItemsToRemove)
|
||||||
|
el.parentElement!.removeChild(el);
|
||||||
|
playerListItemsToRemove.splice(0);
|
||||||
|
playerListItemToRemove = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < playerListNames.length; i++) {
|
||||||
|
playerListNames[i].classList.remove('removed');
|
||||||
|
if (playerListNames[i].parentElement != playerListSlots[i]) {
|
||||||
|
playerListNames[i].parentElement!.removeChild(playerListNames[i]);
|
||||||
|
playerListSlots[i].appendChild(playerListNames[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function lobbySetReady(playerIndex: number) {
|
function lobbySetReady(playerIndex: number) {
|
||||||
playerListItems[playerIndex].className = 'filled ready';
|
playerListNames[playerIndex].classList.add('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function lobbySetOnline(playerIndex: number, isOnline: boolean) {
|
||||||
|
if (isOnline) playerListNames[playerIndex].classList.remove('disconnected');
|
||||||
|
else playerListNames[playerIndex].classList.add('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
function initDeckSelection() {
|
function initDeckSelection() {
|
||||||
|
|
@ -158,11 +294,15 @@ function initDeckSelection() {
|
||||||
lobbyDeckButtons.add(button, deck);
|
lobbyDeckButtons.add(button, deck);
|
||||||
|
|
||||||
buttonElement.addEventListener('click', () => {
|
buttonElement.addEventListener('click', () => {
|
||||||
selectedDeck = deck;
|
if (button.enabled) {
|
||||||
lobbyDeckSubmitButton.disabled = false;
|
selectedDeck = deck;
|
||||||
|
lobbyDeckSubmitButton.disabled = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!deck.isValid) {
|
if (!deck.isValid
|
||||||
|
|| (!currentGame.game.allowUpcomingCards && deck.cards.find(n => cardDatabase.get(n).isUpcoming))
|
||||||
|
|| (!currentGame.game.allowCustomCards && deck.cards.find(n => n <= CUSTOM_CARD_START))) {
|
||||||
button.enabled = false;
|
button.enabled = false;
|
||||||
} else if (deck.name == lastDeckName) {
|
} else if (deck.name == lastDeckName) {
|
||||||
selectedDeck = deck;
|
selectedDeck = deck;
|
||||||
|
|
@ -171,6 +311,7 @@ function initDeckSelection() {
|
||||||
}
|
}
|
||||||
lobbyDeckSubmitButton.disabled = selectedDeck == null;
|
lobbyDeckSubmitButton.disabled = selectedDeck == null;
|
||||||
deckSelectionFormLoadingSection.hidden = true;
|
deckSelectionFormLoadingSection.hidden = true;
|
||||||
|
lobbyStageSection.hidden = true;
|
||||||
lobbyDeckSection.hidden = false;
|
lobbyDeckSection.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
lobbyDeckSection.hidden = true;
|
lobbyDeckSection.hidden = true;
|
||||||
|
|
@ -179,17 +320,44 @@ function initDeckSelection() {
|
||||||
|
|
||||||
lobbyTimeLimitBox.addEventListener('change', () => {
|
lobbyTimeLimitBox.addEventListener('change', () => {
|
||||||
let req = new XMLHttpRequest();
|
let req = new XMLHttpRequest();
|
||||||
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/setTurnTimeLimit`);
|
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/setGameSettings`);
|
||||||
let data = new URLSearchParams();
|
let data = new URLSearchParams();
|
||||||
data.append('clientToken', clientToken);
|
data.append('clientToken', clientToken);
|
||||||
data.append('turnTimeLimit', lobbyTimeLimitBox.value || '');
|
data.append('turnTimeLimit', lobbyTimeLimitBox.value || '');
|
||||||
req.send(data.toString());
|
req.send(data.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lobbyAllowUpcomingCardsBox.addEventListener('change', () => {
|
||||||
|
let req = new XMLHttpRequest();
|
||||||
|
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/setGameSettings`);
|
||||||
|
let data = new URLSearchParams();
|
||||||
|
data.append('clientToken', clientToken);
|
||||||
|
data.append('allowUpcomingCards', lobbyAllowUpcomingCardsBox.checked.toString());
|
||||||
|
req.send(data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
lobbyAllowCustomCardsBox.addEventListener('change', () => {
|
||||||
|
let req = new XMLHttpRequest();
|
||||||
|
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/setGameSettings`);
|
||||||
|
let data = new URLSearchParams();
|
||||||
|
data.append('clientToken', clientToken);
|
||||||
|
data.append('allowCustomCards', lobbyAllowCustomCardsBox.checked.toString());
|
||||||
|
req.send(data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
deckSelectionForm.addEventListener('submit', e => {
|
deckSelectionForm.addEventListener('submit', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedDeck == null) return;
|
if (selectedDeck == null) return;
|
||||||
|
|
||||||
|
const customCards: {[key: number]: Card} = { };
|
||||||
|
let anyCustomCards = false;
|
||||||
|
for (const number of selectedDeck.cards) {
|
||||||
|
if (number <= CUSTOM_CARD_START) {
|
||||||
|
customCards[number] = cardDatabase.get(number);
|
||||||
|
anyCustomCards = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let req = new XMLHttpRequest();
|
let req = new XMLHttpRequest();
|
||||||
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseDeck`);
|
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseDeck`);
|
||||||
req.addEventListener('load', () => {
|
req.addEventListener('load', () => {
|
||||||
|
|
@ -205,6 +373,8 @@ deckSelectionForm.addEventListener('submit', e => {
|
||||||
data.append('deckName', selectedDeck.name);
|
data.append('deckName', selectedDeck.name);
|
||||||
data.append('deckCards', selectedDeck.cards.join('+'));
|
data.append('deckCards', selectedDeck.cards.join('+'));
|
||||||
data.append('deckSleeves', selectedDeck.sleeves.toString());
|
data.append('deckSleeves', selectedDeck.sleeves.toString());
|
||||||
|
if (anyCustomCards)
|
||||||
|
data.append('customCards', JSON.stringify(customCards, submitCustomCardsJsonReplacer));
|
||||||
req.send(data.toString());
|
req.send(data.toString());
|
||||||
|
|
||||||
localStorage.setItem('lastDeckName', selectedDeck.name);
|
localStorage.setItem('lastDeckName', selectedDeck.name);
|
||||||
|
|
@ -212,9 +382,14 @@ deckSelectionForm.addEventListener('submit', e => {
|
||||||
lobbyDeckSubmitButton.disabled = true;
|
lobbyDeckSubmitButton.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function submitCustomCardsJsonReplacer(key: string, value: any) {
|
||||||
|
return key == 'imageUrl' ? undefined : deckExportJsonReplacer(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
stageRandomButton.buttonElement.addEventListener('click', () => {
|
stageRandomButton.buttonElement.addEventListener('click', () => {
|
||||||
stageRandomButton.checked = true;
|
stageRandomButton.checked = true;
|
||||||
stageButtons.deselect();
|
stageButtons.deselect();
|
||||||
|
lobbyStageSubmitButton.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
stageSelectionForm.addEventListener('submit', e => {
|
stageSelectionForm.addEventListener('submit', e => {
|
||||||
|
|
@ -222,20 +397,41 @@ stageSelectionForm.addEventListener('submit', e => {
|
||||||
let req = new XMLHttpRequest();
|
let req = new XMLHttpRequest();
|
||||||
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseStage`);
|
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseStage`);
|
||||||
req.addEventListener('load', () => {
|
req.addEventListener('load', () => {
|
||||||
|
stageSelectionFormLoadingSection.hidden = true;
|
||||||
if (req.status != 204) {
|
if (req.status != 204) {
|
||||||
stageSelectionFormLoadingSection.hidden = true;
|
|
||||||
alert(req.responseText);
|
alert(req.responseText);
|
||||||
lobbyStageSubmitButton.disabled = false;
|
lobbyStageSubmitButton.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.addEventListener('error', () => communicationError());
|
req.addEventListener('error', () => communicationError());
|
||||||
let data = new URLSearchParams();
|
let data = new URLSearchParams();
|
||||||
const stageName = stageRandomButton.checked ? 'random' : stageButtons.value!.name;
|
|
||||||
data.append('clientToken', clientToken);
|
data.append('clientToken', clientToken);
|
||||||
data.append('stage', stageName);
|
data.append('stages', stageButtons.entries.filter(e => e.button.checked).map(e => e.value).join(','));
|
||||||
req.send(data.toString());
|
req.send(data.toString());
|
||||||
|
|
||||||
localStorage.setItem('lastStage', stageName);
|
|
||||||
stageSelectionFormLoadingSection.hidden = false;
|
stageSelectionFormLoadingSection.hidden = false;
|
||||||
lobbyStageSubmitButton.disabled = true;
|
lobbyStageSubmitButton.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
strikeOrderSelectionForm.addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
for (const button of strikeOrderSelectionForm.getElementsByTagName('button'))
|
||||||
|
(<HTMLButtonElement> button).disabled = true;
|
||||||
|
let req = new XMLHttpRequest();
|
||||||
|
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/chooseStage`);
|
||||||
|
req.addEventListener('load', () => {
|
||||||
|
if (req.status != 204) {
|
||||||
|
stageSelectionFormLoadingSection.hidden = true;
|
||||||
|
alert(req.responseText);
|
||||||
|
lobbyStageSubmitButton.disabled = false;
|
||||||
|
for (const button of strikeOrderSelectionForm.getElementsByTagName('button'))
|
||||||
|
(<HTMLButtonElement> button).disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.addEventListener('error', () => communicationError());
|
||||||
|
let data = new URLSearchParams();
|
||||||
|
const number = e.submitter!.dataset.strikeIndex!;
|
||||||
|
data.append('clientToken', clientToken);
|
||||||
|
data.append('stages', number);
|
||||||
|
req.send(data.toString());
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const joinGameButton = document.getElementById('joinGameButton')!;
|
||||||
const nameBox = document.getElementById('nameBox') as HTMLInputElement;
|
const nameBox = document.getElementById('nameBox') as HTMLInputElement;
|
||||||
const gameIDBox = document.getElementById('gameIDBox') as HTMLInputElement;
|
const gameIDBox = document.getElementById('gameIDBox') as HTMLInputElement;
|
||||||
const preGameDeckEditorButton = document.getElementById('preGameDeckEditorButton') as HTMLLinkElement;
|
const preGameDeckEditorButton = document.getElementById('preGameDeckEditorButton') as HTMLLinkElement;
|
||||||
|
const preGameGalleryButton = document.getElementById('preGameGalleryButton') as HTMLLinkElement;
|
||||||
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
|
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
|
||||||
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
|
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
|
||||||
const preGameReplayButton = document.getElementById('preGameReplayButton') as HTMLLinkElement;
|
const preGameReplayButton = document.getElementById('preGameReplayButton') as HTMLLinkElement;
|
||||||
|
|
@ -18,9 +19,34 @@ const gameSetupForm = document.getElementById('gameSetupForm') as HTMLFormElemen
|
||||||
const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElement;
|
const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElement;
|
||||||
const turnTimeLimitBox = document.getElementById('turnTimeLimitBox') as HTMLInputElement;
|
const turnTimeLimitBox = document.getElementById('turnTimeLimitBox') as HTMLInputElement;
|
||||||
const goalWinCountBox = document.getElementById('goalWinCountBox') as HTMLSelectElement;
|
const goalWinCountBox = document.getElementById('goalWinCountBox') as HTMLSelectElement;
|
||||||
|
const gameSetupAllowUpcomingCardsBox = document.getElementById('gameSetupAllowUpcomingCardsBox') as HTMLInputElement;
|
||||||
|
const gameSetupAllowCustomCardsBox = document.getElementById('gameSetupAllowCustomCardsBox') as HTMLInputElement;
|
||||||
|
const stageSelectionRuleFirstBox = document.getElementById('stageSelectionRuleFirstBox') as HTMLSelectElement;
|
||||||
|
const stageSelectionRuleAfterWinBox = document.getElementById('stageSelectionRuleAfterWinBox') as HTMLSelectElement;
|
||||||
|
const stageSelectionRuleAfterDrawBox = document.getElementById('stageSelectionRuleAfterDrawBox') as HTMLSelectElement;
|
||||||
|
const stageSwitch = document.getElementById('stageSwitch')!;
|
||||||
|
const stageSwitchButtons: HTMLButtonElement[] = [ ];
|
||||||
|
const gameSetupForceSameDeckAfterDrawBox = document.getElementById('gameSetupForceSameDeckAfterDrawBox') as HTMLInputElement;
|
||||||
|
const gameSetupSpectateBox = document.getElementById('gameSetupSpectateBox') as HTMLInputElement;
|
||||||
|
const gameSetupSubmitButton = document.getElementById('gameSetupSubmitButton') as HTMLButtonElement;
|
||||||
|
|
||||||
const optionsColourLock = document.getElementById('optionsColourLock') as HTMLInputElement;
|
const optionsColourLock = document.getElementById('optionsColourLock') as HTMLInputElement;
|
||||||
|
const optionsColourGoodBox = document.getElementById('optionsColourGoodBox') as HTMLSelectElement;
|
||||||
|
const optionsColourBadBox = document.getElementById('optionsColourBadBox') as HTMLSelectElement;
|
||||||
const optionsTurnNumberStyle = document.getElementById('optionsTurnNumberStyle') as HTMLSelectElement;
|
const optionsTurnNumberStyle = document.getElementById('optionsTurnNumberStyle') as HTMLSelectElement;
|
||||||
|
const optionsSpecialWeaponSorting = document.getElementById('optionsSpecialWeaponSorting') as HTMLSelectElement;
|
||||||
|
|
||||||
|
const colours = {
|
||||||
|
red: { colour: { r: 0xf2, g: 0x20, b: 0x0d }, specialColour: { r: 0xff, g: 0x8c, b: 0x1a }, specialAccentColour: { r: 0xff, g: 0xd5, b: 0xcc }, uiBaseColourIsSpecialColour: false },
|
||||||
|
orange: { colour: { r: 0xf2, g: 0x74, b: 0x0d }, specialColour: { r: 0xff, g: 0x40, b: 0x00 }, specialAccentColour: { r: 0xff, g: 0xcc, b: 0x99 }, uiBaseColourIsSpecialColour: true },
|
||||||
|
yellow: { colour: { r: 0xec, g: 0xf9, b: 0x01 }, specialColour: { r: 0xfa, g: 0x9e, b: 0x00 }, specialAccentColour: { r: 0xf9, g: 0xf9, b: 0x1f }, uiBaseColourIsSpecialColour: true },
|
||||||
|
limegreen: { colour: { r: 0xc0, g: 0xf9, b: 0x15 }, specialColour: { r: 0x6a, g: 0xff, b: 0x00 }, specialAccentColour: { r: 0xe6, g: 0xff, b: 0x99 }, uiBaseColourIsSpecialColour: true },
|
||||||
|
green: { colour: { r: 0x06, g: 0xe0, b: 0x06 }, specialColour: { r: 0x33, g: 0xff, b: 0xcc }, specialAccentColour: { r: 0xb3, g: 0xff, b: 0xd9 }, uiBaseColourIsSpecialColour: false },
|
||||||
|
turquoise: { colour: { r: 0x00, g: 0xff, b: 0xea }, specialColour: { r: 0x00, g: 0xa8, b: 0xe0 }, specialAccentColour: { r: 0x99, g: 0xff, b: 0xff }, uiBaseColourIsSpecialColour: true },
|
||||||
|
blue: { colour: { r: 0x4a, g: 0x5c, b: 0xfc }, specialColour: { r: 0x01, g: 0xed, b: 0xfe }, specialAccentColour: { r: 0xd5, g: 0xe1, b: 0xe1 }, uiBaseColourIsSpecialColour: false },
|
||||||
|
purple: { colour: { r: 0xa1, g: 0x06, b: 0xef }, specialColour: { r: 0xff, g: 0x00, b: 0xff }, specialAccentColour: { r: 0xff, g: 0xb3, b: 0xff }, uiBaseColourIsSpecialColour: false },
|
||||||
|
magenta: { colour: { r: 0xf9, g: 0x06, b: 0xe0 }, specialColour: { r: 0x80, g: 0x06, b: 0xf9 }, specialAccentColour: { r: 0xeb, g: 0xb4, b: 0xfd }, uiBaseColourIsSpecialColour: true },
|
||||||
|
};
|
||||||
|
|
||||||
let shownMaxPlayersWarning = false;
|
let shownMaxPlayersWarning = false;
|
||||||
|
|
||||||
|
|
@ -34,6 +60,42 @@ function setLoadingMessage(message: string | null) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preGameInitStageDatabase(stages: Stage[]) {
|
||||||
|
for (let i = 0; i < stages.length; i++) {
|
||||||
|
const stage = stages[i];
|
||||||
|
const status = userConfig.lastCustomRoomConfig && userConfig.lastCustomRoomConfig.stageSwitch.length > i
|
||||||
|
? userConfig.lastCustomRoomConfig.stageSwitch[i]
|
||||||
|
: (stages[i].name.startsWith('Upcoming') ? 2 : 0);
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
|
||||||
|
const div1 = document.createElement('div');
|
||||||
|
div1.className = 'stageName';
|
||||||
|
div1.innerText = stage.name;
|
||||||
|
button.appendChild(div1);
|
||||||
|
|
||||||
|
const div2 = document.createElement('div');
|
||||||
|
div2.className = 'stageStatus';
|
||||||
|
div2.innerText = [ 'Allowed', 'Counterpick only', 'Banned' ][status];
|
||||||
|
button.appendChild(div2);
|
||||||
|
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.index = stageSwitchButtons.length.toString();
|
||||||
|
button.dataset.status = status.toString();
|
||||||
|
stageSwitchButtons.push(button);
|
||||||
|
button.addEventListener('click', stageSwitchButton_click);
|
||||||
|
stageSwitch.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageSwitchButton_click(e: Event) {
|
||||||
|
const button = e.currentTarget as HTMLButtonElement;
|
||||||
|
let status = button.dataset.status == '0' ? 1 : button.dataset.status == '1' ? 2 : 0;
|
||||||
|
button.dataset.status = status.toString();
|
||||||
|
(<HTMLElement>button.getElementsByClassName('stageStatus')[0]).innerText = [ 'Allowed', 'Counterpick only', 'Banned' ][status];
|
||||||
|
updateCreateRoomButton();
|
||||||
|
}
|
||||||
|
|
||||||
maxPlayersBox.addEventListener('change', () => {
|
maxPlayersBox.addEventListener('change', () => {
|
||||||
if (!shownMaxPlayersWarning && maxPlayersBox.value != '2') {
|
if (!shownMaxPlayersWarning && maxPlayersBox.value != '2') {
|
||||||
if (confirm('Tableturf Battle is designed for two players and may not be well-balanced for more. Do you want to continue?'))
|
if (confirm('Tableturf Battle is designed for two players and may not be well-balanced for more. Do you want to continue?'))
|
||||||
|
|
@ -41,8 +103,18 @@ maxPlayersBox.addEventListener('change', () => {
|
||||||
else
|
else
|
||||||
maxPlayersBox.value = '2';
|
maxPlayersBox.value = '2';
|
||||||
}
|
}
|
||||||
|
const maxPlayers = parseInt(maxPlayersBox.value);
|
||||||
|
for (let i = 0; i < stageDatabase.stages!.length; i++) {
|
||||||
|
stageSwitchButtons[i].disabled = maxPlayers > stageDatabase.stages![i].maxPlayers;
|
||||||
|
}
|
||||||
|
updateCreateRoomButton();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function updateCreateRoomButton() {
|
||||||
|
const maxPlayers = parseInt(maxPlayersBox.value);
|
||||||
|
gameSetupSubmitButton.disabled = stageSwitchButtons.every((b, i) => b.dataset.status != '0' || maxPlayers > stageDatabase.stages![i].maxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
newGameSetupButton.addEventListener('click', _ => {
|
newGameSetupButton.addEventListener('click', _ => {
|
||||||
gameSetupDialog.showModal();
|
gameSetupDialog.showModal();
|
||||||
});
|
});
|
||||||
|
|
@ -110,11 +182,49 @@ function createRoom(useOptionsForm: boolean) {
|
||||||
data.append('name', name);
|
data.append('name', name);
|
||||||
data.append('clientToken', clientToken);
|
data.append('clientToken', clientToken);
|
||||||
if (useOptionsForm) {
|
if (useOptionsForm) {
|
||||||
|
const maxPlayers = parseInt(maxPlayersBox.value);
|
||||||
|
const settings = <CustomRoomConfig> {
|
||||||
|
maxPlayers,
|
||||||
|
turnTimeLimit: turnTimeLimitBox.value ? turnTimeLimitBox.valueAsNumber : null,
|
||||||
|
goalWinCount: goalWinCountBox.value ? parseInt(goalWinCountBox.value) : null,
|
||||||
|
allowUpcomingCards: gameSetupAllowUpcomingCardsBox.checked,
|
||||||
|
allowCustomCards: gameSetupAllowCustomCardsBox.checked,
|
||||||
|
stageSelectionMethodFirst: StageSelectionMethod[stageSelectionRuleFirstBox.value as keyof typeof StageSelectionMethod],
|
||||||
|
stageSelectionMethodAfterWin: stageSelectionRuleAfterWinBox.value == 'Inherit' ? null : StageSelectionMethod[stageSelectionRuleAfterWinBox.value as keyof typeof StageSelectionMethod],
|
||||||
|
stageSelectionMethodAfterDraw: stageSelectionRuleAfterDrawBox.value == 'Inherit' ? null : StageSelectionMethod[stageSelectionRuleAfterDrawBox.value as keyof typeof StageSelectionMethod],
|
||||||
|
forceSameDecksAfterDraw: gameSetupForceSameDeckAfterDrawBox.checked,
|
||||||
|
stageSwitch: stageSwitchButtons.map(b => parseInt(b.dataset.status!)),
|
||||||
|
spectate: gameSetupSpectateBox.checked
|
||||||
|
};
|
||||||
|
userConfig.lastCustomRoomConfig = settings;
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
data.append('maxPlayers', maxPlayersBox.value);
|
data.append('maxPlayers', maxPlayersBox.value);
|
||||||
if (turnTimeLimitBox.value)
|
if (turnTimeLimitBox.value)
|
||||||
data.append('turnTimeLimit', turnTimeLimitBox.value);
|
data.append('turnTimeLimit', turnTimeLimitBox.value);
|
||||||
if (goalWinCountBox.value)
|
if (goalWinCountBox.value)
|
||||||
data.append('goalWinCount', goalWinCountBox.value);
|
data.append('goalWinCount', goalWinCountBox.value);
|
||||||
|
data.append('allowUpcomingCards', settings.allowUpcomingCards.toString());
|
||||||
|
data.append('allowCustomCards', settings.allowCustomCards.toString());
|
||||||
|
|
||||||
|
const stageSelectionRuleFirst = {
|
||||||
|
method: settings.stageSelectionMethodFirst,
|
||||||
|
bannedStages: settings.stageSwitch.map((_, i) => i).filter(i => settings.stageSwitch[i] != 0)
|
||||||
|
};
|
||||||
|
const stageSelectionRuleAfterWin = {
|
||||||
|
method: settings.stageSelectionMethodAfterWin ?? settings.stageSelectionMethodFirst,
|
||||||
|
bannedStages: settings.stageSwitch.map((_, i) => i).filter(i => settings.stageSwitch[i] == 2)
|
||||||
|
};
|
||||||
|
const stageSelectionRuleAfterDraw = {
|
||||||
|
method: settings.stageSelectionMethodAfterDraw ?? settings.stageSelectionMethodFirst,
|
||||||
|
bannedStages: settings.stageSwitch.map((_, i) => i).filter(i => settings.stageSwitch[i] == 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
data.append('stageSelectionRuleFirst', JSON.stringify(stageSelectionRuleFirst));
|
||||||
|
data.append('stageSelectionRuleAfterWin', JSON.stringify(stageSelectionRuleAfterWin));
|
||||||
|
data.append('stageSelectionRuleAfterDraw', JSON.stringify(stageSelectionRuleAfterDraw));
|
||||||
|
data.append('forceSameDeckAfterDraw', settings.forceSameDecksAfterDraw.toString());
|
||||||
|
data.append('spectate', settings.spectate.toString());
|
||||||
}
|
}
|
||||||
request.send(data.toString());
|
request.send(data.toString());
|
||||||
setLoadingMessage('Creating a room...');
|
setLoadingMessage('Creating a room...');
|
||||||
|
|
@ -166,6 +276,7 @@ function joinGameError(message: string, fromInitialLoad: boolean) {
|
||||||
if (fromInitialLoad)
|
if (fromInitialLoad)
|
||||||
clearPreGameForm(true);
|
clearPreGameForm(true);
|
||||||
else {
|
else {
|
||||||
|
showPage('preGame');
|
||||||
gameIDBox.focus();
|
gameIDBox.focus();
|
||||||
gameIDBox.setSelectionRange(0, gameIDBox.value.length);
|
gameIDBox.setSelectionRange(0, gameIDBox.value.length);
|
||||||
}
|
}
|
||||||
|
|
@ -208,9 +319,18 @@ preGameDeckEditorButton.addEventListener('click', e => {
|
||||||
setUrl('deckeditor');
|
setUrl('deckeditor');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
preGameGalleryButton.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
showCardList();
|
||||||
|
setUrl('cardlist');
|
||||||
|
});
|
||||||
|
|
||||||
preGameSettingsButton.addEventListener('click', e => {
|
preGameSettingsButton.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
optionsColourGoodBox.value = userConfig.goodColour ?? 'yellow';
|
||||||
|
optionsColourBadBox.value = userConfig.badColour ?? 'blue';
|
||||||
optionsTurnNumberStyle.value = turnNumberLabel.absoluteMode ? 'absolute' : 'remaining';
|
optionsTurnNumberStyle.value = turnNumberLabel.absoluteMode ? 'absolute' : 'remaining';
|
||||||
|
optionsSpecialWeaponSorting.value = SpecialWeaponSorting[userConfig.specialWeaponSorting];
|
||||||
settingsDialog.showModal();
|
settingsDialog.showModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -246,21 +366,51 @@ preGameReplayButton.addEventListener('click', e => {
|
||||||
new ReplayLoader(m[1]).loadReplay();
|
new ReplayLoader(m[1]).loadReplay();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setPreferredColours() {
|
||||||
|
const colour1 = colours[(userConfig.goodColour ?? 'yellow') as keyof typeof colours];
|
||||||
|
document.body.style.setProperty('--primary-colour-1', `rgb(${colour1.colour.r}, ${colour1.colour.g}, ${colour1.colour.b})`);
|
||||||
|
document.body.style.setProperty('--special-colour-1', `rgb(${colour1.specialColour.r}, ${colour1.specialColour.g}, ${colour1.specialColour.b})`);
|
||||||
|
document.body.style.setProperty('--special-accent-colour-1', `rgb(${colour1.specialAccentColour.r}, ${colour1.specialAccentColour.g}, ${colour1.specialAccentColour.b})`);
|
||||||
|
uiBaseColourIsSpecialColourPerPlayer[0] = colour1.uiBaseColourIsSpecialColour;
|
||||||
|
uiBaseColourIsSpecialColourOutOfGame = colour1.uiBaseColourIsSpecialColour;
|
||||||
|
gamePage.dataset.uiBaseColourIsSpecialColour = uiBaseColourIsSpecialColourOutOfGame.toString();
|
||||||
|
|
||||||
|
const colour2 = colours[(userConfig.badColour ?? 'blue') as keyof typeof colours];
|
||||||
|
document.body.style.setProperty('--primary-colour-2', `rgb(${colour2.colour.r}, ${colour2.colour.g}, ${colour2.colour.b})`);
|
||||||
|
document.body.style.setProperty('--special-colour-2', `rgb(${colour2.specialColour.r}, ${colour2.specialColour.g}, ${colour2.specialColour.b})`);
|
||||||
|
document.body.style.setProperty('--special-accent-colour-2', `rgb(${colour2.specialAccentColour.r}, ${colour2.specialAccentColour.g}, ${colour2.specialAccentColour.b})`);
|
||||||
|
uiBaseColourIsSpecialColourPerPlayer[1] = colour2.uiBaseColourIsSpecialColour;
|
||||||
|
|
||||||
|
for (let i = 3; i <= 4; i++) {
|
||||||
|
document.body.style.removeProperty(`--primary-colour-${i}`);
|
||||||
|
document.body.style.removeProperty(`--special-colour-${i}`);
|
||||||
|
document.body.style.removeProperty(`--special-accent-colour-${i}`);
|
||||||
|
uiBaseColourIsSpecialColourPerPlayer[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
optionsColourLock.addEventListener('change', () => {
|
optionsColourLock.addEventListener('change', () => {
|
||||||
userConfig.colourLock = optionsColourLock.checked;
|
userConfig.colourLock = optionsColourLock.checked;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
if (userConfig.colourLock) {
|
setPreferredColours();
|
||||||
for (let i = 1; i <= 4; i++) {
|
});
|
||||||
document.body.style.removeProperty(`--primary-colour-${i}`);
|
|
||||||
document.body.style.removeProperty(`--special-colour-${i}`);
|
optionsColourGoodBox.addEventListener('change', () => {
|
||||||
document.body.style.removeProperty(`--special-accent-colour-${i}`);
|
userConfig.goodColour = optionsColourGoodBox.value;
|
||||||
uiBaseColourIsSpecialColourOutOfGame = true;
|
saveSettings();
|
||||||
gamePage.dataset.uiBaseColourIsSpecialColour = 'true';
|
setPreferredColours();
|
||||||
}
|
});
|
||||||
}
|
optionsColourBadBox.addEventListener('change', () => {
|
||||||
})
|
userConfig.badColour = optionsColourBadBox.value;
|
||||||
|
saveSettings();
|
||||||
|
setPreferredColours();
|
||||||
|
});
|
||||||
|
|
||||||
optionsTurnNumberStyle.addEventListener('change', () => turnNumberLabel.absoluteMode = optionsTurnNumberStyle.value == 'absolute');
|
optionsTurnNumberStyle.addEventListener('change', () => turnNumberLabel.absoluteMode = optionsTurnNumberStyle.value == 'absolute');
|
||||||
|
optionsSpecialWeaponSorting.addEventListener('change', () => {
|
||||||
|
userConfig.specialWeaponSorting = SpecialWeaponSorting[optionsSpecialWeaponSorting.value as keyof typeof SpecialWeaponSorting];
|
||||||
|
saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
let playerName = localStorage.getItem('name');
|
let playerName = localStorage.getItem('name');
|
||||||
(document.getElementById('nameBox') as HTMLInputElement).value = playerName || '';
|
(document.getElementById('nameBox') as HTMLInputElement).value = playerName || '';
|
||||||
|
|
@ -274,8 +424,27 @@ window.addEventListener('popstate', () => {
|
||||||
// Initialise the settings dialog.
|
// Initialise the settings dialog.
|
||||||
{
|
{
|
||||||
optionsColourLock.checked = userConfig.colourLock;
|
optionsColourLock.checked = userConfig.colourLock;
|
||||||
|
optionsColourGoodBox.value = userConfig.goodColour ?? 'yellow';
|
||||||
|
optionsColourBadBox.value = userConfig.badColour ?? 'blue';
|
||||||
|
setPreferredColours();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canPushState)
|
// Initialise the room settings dialog.
|
||||||
|
{
|
||||||
|
if (userConfig.lastCustomRoomConfig) {
|
||||||
|
maxPlayersBox.value = userConfig.lastCustomRoomConfig.maxPlayers.toString();
|
||||||
|
turnTimeLimitBox.value = userConfig.lastCustomRoomConfig.turnTimeLimit?.toString() ?? '';
|
||||||
|
goalWinCountBox.value = userConfig.lastCustomRoomConfig.goalWinCount?.toString() ?? '';
|
||||||
|
gameSetupAllowUpcomingCardsBox.checked = userConfig.lastCustomRoomConfig.allowUpcomingCards ?? true;
|
||||||
|
stageSelectionRuleFirstBox.value = StageSelectionMethod[userConfig.lastCustomRoomConfig.stageSelectionMethodFirst]
|
||||||
|
stageSelectionRuleAfterWinBox.value = userConfig.lastCustomRoomConfig.stageSelectionMethodAfterWin != null ? StageSelectionMethod[userConfig.lastCustomRoomConfig.stageSelectionMethodAfterWin] : 'Inherit';
|
||||||
|
stageSelectionRuleAfterDrawBox.value = userConfig.lastCustomRoomConfig.stageSelectionMethodAfterDraw != null ? StageSelectionMethod[userConfig.lastCustomRoomConfig.stageSelectionMethodAfterDraw] : 'Inherit';
|
||||||
|
gameSetupForceSameDeckAfterDrawBox.checked = userConfig.lastCustomRoomConfig.forceSameDecksAfterDraw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canPushState) {
|
||||||
preGameDeckEditorButton.href = '#deckeditor';
|
preGameDeckEditorButton.href = '#deckeditor';
|
||||||
|
preGameGalleryButton.href = '#cardlist';
|
||||||
|
}
|
||||||
setLoadingMessage('Loading game data...');
|
setLoadingMessage('Loading game data...');
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ interface Player {
|
||||||
name: string;
|
name: string;
|
||||||
specialPoints: number;
|
specialPoints: number;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
|
isOnline: boolean;
|
||||||
colour: Colour;
|
colour: Colour;
|
||||||
specialColour: Colour;
|
specialColour: Colour;
|
||||||
specialAccentColour: Colour;
|
specialAccentColour: Colour;
|
||||||
uiBaseColourIsSpecialColour?: boolean;
|
uiBaseColourIsSpecialColour: boolean;
|
||||||
sleeves: number;
|
sleeves: number;
|
||||||
totalSpecialPoints: number;
|
totalSpecialPoints: number;
|
||||||
passes: number;
|
passes: number;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,15 @@ class PlayerBar {
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return this.nameElement.innerText; }
|
get name() { return this.nameElement.innerText; }
|
||||||
set name(value: string) { this.nameElement.innerText = value; }
|
set name(value: string) {
|
||||||
|
this.nameElement.innerText = value;
|
||||||
|
|
||||||
|
const el2 = document.createElement('img');
|
||||||
|
el2.src = 'assets/wifi-off.svg';
|
||||||
|
el2.className = 'disconnectedIcon';
|
||||||
|
el2.title = 'Disconnected';
|
||||||
|
this.nameElement.appendChild(el2);
|
||||||
|
}
|
||||||
|
|
||||||
get points() { return parseInt(this.pointsElement.innerText); }
|
get points() { return parseInt(this.pointsElement.innerText); }
|
||||||
set points(value: number) { this.pointsElement.innerText = value.toString(); }
|
set points(value: number) { this.pointsElement.innerText = value.toString(); }
|
||||||
|
|
@ -116,4 +124,9 @@ class PlayerBar {
|
||||||
this.element.hidden = !value;
|
this.element.hidden = !value;
|
||||||
this.pointsContainer.hidden = !value;
|
this.pointsContainer.hidden = !value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOnline(value: boolean) {
|
||||||
|
if (value) this.element.classList.remove('disconnected');
|
||||||
|
else this.element.classList.add('disconnected');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,20 @@ interface PlayerData {
|
||||||
deck: Deck | null;
|
deck: Deck | null;
|
||||||
cardsUsed: number[];
|
cardsUsed: number[];
|
||||||
move: Move | null;
|
move: Move | null;
|
||||||
|
stageSelectionPrompt: StageSelectionPrompt | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StageSelectionPrompt {
|
||||||
|
promptType: StageSelectionPromptType;
|
||||||
|
numberOfStagesToStrike: number;
|
||||||
|
struckStages: number[] | null;
|
||||||
|
bannedStages: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StageSelectionPromptType {
|
||||||
|
Vote,
|
||||||
|
VoteOrder,
|
||||||
|
Strike,
|
||||||
|
Choose,
|
||||||
|
Wait
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class ReplayLoader {
|
||||||
|
|
||||||
const version = this.readUint8();
|
const version = this.readUint8();
|
||||||
const players: Player[] = [ ];
|
const players: Player[] = [ ];
|
||||||
|
const customCards: Card[] = [ ];
|
||||||
let goalWinCount = null;
|
let goalWinCount = null;
|
||||||
switch (version) {
|
switch (version) {
|
||||||
case 1: {
|
case 1: {
|
||||||
|
|
@ -35,7 +36,7 @@ class ReplayLoader {
|
||||||
const initialDrawOrder = [ ];
|
const initialDrawOrder = [ ];
|
||||||
const drawOrder = [ ];
|
const drawOrder = [ ];
|
||||||
for (let j = 0; j < 15; j++) {
|
for (let j = 0; j < 15; j++) {
|
||||||
cards.push(this.readCard());
|
cards.push(this.readCard(version, customCards));
|
||||||
}
|
}
|
||||||
for (let j = 0; j < 2; j++) {
|
for (let j = 0; j < 2; j++) {
|
||||||
const n = this.readUint8();
|
const n = this.readUint8();
|
||||||
|
|
@ -55,6 +56,7 @@ class ReplayLoader {
|
||||||
name: this.readString(),
|
name: this.readString(),
|
||||||
specialPoints: 0,
|
specialPoints: 0,
|
||||||
isReady: false,
|
isReady: false,
|
||||||
|
isOnline: true,
|
||||||
colour,
|
colour,
|
||||||
specialColour,
|
specialColour,
|
||||||
specialAccentColour,
|
specialAccentColour,
|
||||||
|
|
@ -65,10 +67,10 @@ class ReplayLoader {
|
||||||
gamesWon: 0
|
gamesWon: 0
|
||||||
};
|
};
|
||||||
players.push(player);
|
players.push(player);
|
||||||
playerData.push({ deck: new Deck("Deck", 0, cards, new Array(15)), initialDrawOrder, drawOrder, won: false });
|
playerData.push({ deck: new Deck("Deck", 0, cards, new Array(15).fill(1)), initialDrawOrder, drawOrder, won: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const turns = this.readTurns(numPlayers);
|
const turns = this.readTurns(numPlayers, version, customCards);
|
||||||
currentReplay.games.push({ stage, playerData, turns });
|
currentReplay.games.push({ stage, playerData, turns });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +92,7 @@ class ReplayLoader {
|
||||||
const drawOrder = [ ];
|
const drawOrder = [ ];
|
||||||
let won = false;
|
let won = false;
|
||||||
for (let j = 0; j < 15; j++) {
|
for (let j = 0; j < 15; j++) {
|
||||||
cards.push(this.readCard());
|
cards.push(this.readCard(version, customCards));
|
||||||
}
|
}
|
||||||
for (let j = 0; j < 2; j++) {
|
for (let j = 0; j < 2; j++) {
|
||||||
const n = this.readUint8();
|
const n = this.readUint8();
|
||||||
|
|
@ -105,14 +107,14 @@ class ReplayLoader {
|
||||||
else
|
else
|
||||||
drawOrder.push(n >> 4 & 0xF);
|
drawOrder.push(n >> 4 & 0xF);
|
||||||
}
|
}
|
||||||
playerData.push({ deck: new Deck("Deck", 0, cards, new Array(15)), initialDrawOrder, drawOrder, won });
|
playerData.push({ deck: new Deck("Deck", 0, cards, new Array(15).fill(1)), initialDrawOrder, drawOrder, won });
|
||||||
}
|
}
|
||||||
const turns = this.readTurns(numPlayers);
|
const turns = this.readTurns(numPlayers, version, customCards);
|
||||||
currentReplay.games.push({ stage, playerData, turns });
|
currentReplay.games.push({ stage, playerData, turns });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 3: {
|
case 3: case 4: case 5: {
|
||||||
const n = this.readUint8();
|
const n = this.readUint8();
|
||||||
const numPlayers = n & 0x0F;
|
const numPlayers = n & 0x0F;
|
||||||
goalWinCount = n >> 4;
|
goalWinCount = n >> 4;
|
||||||
|
|
@ -121,6 +123,35 @@ class ReplayLoader {
|
||||||
currentReplay = { gameNumber: 0, decks: [ ], games: [ ], turns: [ ], placements: [ ], watchingPlayer: 0 };
|
currentReplay = { gameNumber: 0, decks: [ ], games: [ ], turns: [ ], placements: [ ], watchingPlayer: 0 };
|
||||||
this.readPlayers(numPlayers, players);
|
this.readPlayers(numPlayers, players);
|
||||||
|
|
||||||
|
// Custom cards
|
||||||
|
if (version >= 4) {
|
||||||
|
const numCustomCards = this.read7BitEncodedInt();
|
||||||
|
for (let i = 0; i < numCustomCards; i++) {
|
||||||
|
const line1 = this.readString();
|
||||||
|
const line2 = this.readString();
|
||||||
|
const name = line2 != '' ? `${line1} ${line2}` : line1;
|
||||||
|
const b = this.readUint8();
|
||||||
|
const rarity = <Rarity> b;
|
||||||
|
const specialCost = this.readUint8();
|
||||||
|
const inkColour1 = this.readColour();
|
||||||
|
const inkColour2 = this.readColour();
|
||||||
|
const grid = [ ];
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
const row = [ ];
|
||||||
|
for (let y = 0; y < 8; y += 4) {
|
||||||
|
const b = this.readUint8();
|
||||||
|
row.push(<Space> ((b & 0x03) << 2));
|
||||||
|
row.push(<Space> (b & 0x0c));
|
||||||
|
row.push(<Space> ((b & 0x30) >> 2));
|
||||||
|
row.push(<Space> ((b & 0xc0) >> 4));
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
const card = new Card(RECEIVED_CUSTOM_CARD_START - i, name, line1, line2 == '' ? null : line2, inkColour1, inkColour2, rarity, specialCost, grid);
|
||||||
|
customCards.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decks
|
// Decks
|
||||||
const decks = [ ];
|
const decks = [ ];
|
||||||
const numDecks = this.read7BitEncodedInt();
|
const numDecks = this.read7BitEncodedInt();
|
||||||
|
|
@ -128,7 +159,7 @@ class ReplayLoader {
|
||||||
const name = this.readString();
|
const name = this.readString();
|
||||||
const sleeves = this.readUint8();
|
const sleeves = this.readUint8();
|
||||||
const cards = [ ];
|
const cards = [ ];
|
||||||
for (let i = 0; i < 15; i++) cards.push(this.readCard());
|
for (let i = 0; i < 15; i++) cards.push(this.readCard(version, customCards));
|
||||||
const upgrades = [ ];
|
const upgrades = [ ];
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const b = this.readUint8();
|
const b = this.readUint8();
|
||||||
|
|
@ -164,7 +195,7 @@ class ReplayLoader {
|
||||||
}
|
}
|
||||||
playerData.push({ deck, initialDrawOrder, drawOrder, won });
|
playerData.push({ deck, initialDrawOrder, drawOrder, won });
|
||||||
}
|
}
|
||||||
const turns = this.readTurns(numPlayers);
|
const turns = this.readTurns(numPlayers, version, customCards);
|
||||||
currentReplay.games.push({ stage, playerData, turns });
|
currentReplay.games.push({ stage, playerData, turns });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -182,9 +213,12 @@ class ReplayLoader {
|
||||||
turnNumber: 0,
|
turnNumber: 0,
|
||||||
turnTimeLimit: null,
|
turnTimeLimit: null,
|
||||||
turnTimeLeft: null,
|
turnTimeLeft: null,
|
||||||
goalWinCount: goalWinCount
|
goalWinCount: goalWinCount,
|
||||||
|
allowUpcomingCards: true,
|
||||||
|
allowCustomCards: true
|
||||||
},
|
},
|
||||||
me: null,
|
me: null,
|
||||||
|
isHost: false,
|
||||||
webSocket: null
|
webSocket: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,6 +229,11 @@ class ReplayLoader {
|
||||||
|
|
||||||
private readUint8() { return this.dataView.getUint8(this.pos++); }
|
private readUint8() { return this.dataView.getUint8(this.pos++); }
|
||||||
private readInt8() { return this.dataView.getInt8(this.pos++); }
|
private readInt8() { return this.dataView.getInt8(this.pos++); }
|
||||||
|
private readInt16() {
|
||||||
|
const v = this.dataView.getInt16(this.pos, true);
|
||||||
|
this.pos += 2;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
private readColour(): Colour { return { r: this.readUint8(), g: this.readUint8(), b: this.readUint8() }; }
|
private readColour(): Colour { return { r: this.readUint8(), g: this.readUint8(), b: this.readUint8() }; }
|
||||||
private readString(length?: number) {
|
private readString(length?: number) {
|
||||||
length ??= this.read7BitEncodedInt();
|
length ??= this.read7BitEncodedInt();
|
||||||
|
|
@ -226,6 +265,7 @@ class ReplayLoader {
|
||||||
name: this.readString(len),
|
name: this.readString(len),
|
||||||
specialPoints: 0,
|
specialPoints: 0,
|
||||||
isReady: false,
|
isReady: false,
|
||||||
|
isOnline: true,
|
||||||
colour,
|
colour,
|
||||||
specialColour,
|
specialColour,
|
||||||
specialAccentColour,
|
specialAccentColour,
|
||||||
|
|
@ -239,12 +279,12 @@ class ReplayLoader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readTurns(numPlayers: number) {
|
private readTurns(numPlayers: number, version: number, customCards: Card[]) {
|
||||||
const turns = [ ];
|
const turns = [ ];
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const turn = [ ];
|
const turn = [ ];
|
||||||
for (let j = 0; j < numPlayers; j++) {
|
for (let j = 0; j < numPlayers; j++) {
|
||||||
const card = this.readCard();
|
const card = this.readCard(version, customCards);
|
||||||
const b = this.readUint8();
|
const b = this.readUint8();
|
||||||
const x = this.readInt8();
|
const x = this.readInt8();
|
||||||
const y = this.readInt8();
|
const y = this.readInt8();
|
||||||
|
|
@ -252,6 +292,15 @@ class ReplayLoader {
|
||||||
turn.push({ card, isPass: true, isTimeout: (b & 0x20) != 0 });
|
turn.push({ card, isPass: true, isTimeout: (b & 0x20) != 0 });
|
||||||
else {
|
else {
|
||||||
const move: PlayMove = { card, isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
|
const move: PlayMove = { card, isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
|
||||||
|
if (version < 5 && card.number == 217) {
|
||||||
|
// Heavy Edit Splatling: originally had the ink pattern transposed down one space
|
||||||
|
switch (move.rotation) {
|
||||||
|
case 0: move.y++; break;
|
||||||
|
case 1: move.x--; break;
|
||||||
|
case 2: move.y--; break;
|
||||||
|
default: move.x++; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
turn.push(move);
|
turn.push(move);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,8 +309,13 @@ class ReplayLoader {
|
||||||
return turns;
|
return turns;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readCard() {
|
private readCard(version: number, customCards: Card[]) {
|
||||||
const num = this.readUint8();
|
if (version >= 4) {
|
||||||
return cardDatabase.get(num > cardDatabase.lastOfficialCardNumber ? num - 256 : num);
|
const num = this.readInt16();
|
||||||
|
return num <= RECEIVED_CUSTOM_CARD_START ? customCards[RECEIVED_CUSTOM_CARD_START - num] : cardDatabase.get(num);
|
||||||
|
} else {
|
||||||
|
const num = this.readUint8();
|
||||||
|
return cardDatabase.get(num > cardDatabase.lastOfficialCardNumber ? num - 256 : num);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ class Stage {
|
||||||
return new Stage(obj.name, obj.grid, obj.startSpaces);
|
return new Stage(obj.name, obj.grid, obj.startSpaces);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get maxPlayers() { return Math.max(...this.startSpaces.map(a => a.length)); }
|
||||||
|
|
||||||
getStartSpaces(numPlayers: number) {
|
getStartSpaces(numPlayers: number) {
|
||||||
let list = null as Point[] | null;
|
let list = null as Point[] | null;
|
||||||
for (const list2 of this.startSpaces) {
|
for (const list2 of this.startSpaces) {
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,36 @@ class StageButton extends CheckButton {
|
||||||
for (var x = 0; x < stage.grid.length; x++) {
|
for (var x = 0; x < stage.grid.length; x++) {
|
||||||
let col = [ ];
|
let col = [ ];
|
||||||
for (var y = 0; y < stage.grid[x].length; y++) {
|
for (var y = 0; y < stage.grid[x].length; y++) {
|
||||||
if (stage.grid[x][y] == Space.Empty) {
|
if (stage.grid[x][y] == Space.OutOfBounds)
|
||||||
|
col.push(null);
|
||||||
|
else {
|
||||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
rect.classList.add('empty');
|
rect.classList.add(Space[stage.grid[x][y]].toString());
|
||||||
rect.setAttribute('x', (100 * x).toString());
|
rect.setAttribute('x', (100 * x).toString());
|
||||||
rect.setAttribute('y', (100 * y + offset).toString());
|
rect.setAttribute('y', (100 * y + offset).toString());
|
||||||
rect.setAttribute('width', '100');
|
rect.setAttribute('width', '100');
|
||||||
rect.setAttribute('height', '100');
|
rect.setAttribute('height', '100');
|
||||||
gridSvg.appendChild(rect);
|
gridSvg.appendChild(rect);
|
||||||
col.push(rect);
|
col.push(rect);
|
||||||
} else
|
|
||||||
col.push(null);
|
if (stage.grid[x][y] & Space.SpecialInactive1) {
|
||||||
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
|
image.setAttribute('href', 'assets/SpecialOverlay.webp');
|
||||||
|
image.setAttribute('x', rect.getAttribute('x')!);
|
||||||
|
image.setAttribute('y', rect.getAttribute('y')!);
|
||||||
|
image.setAttribute('width', rect.getAttribute('width')!);
|
||||||
|
image.setAttribute('height', rect.getAttribute('height')!);
|
||||||
|
gridSvg.appendChild(image);
|
||||||
|
} else if (stage.grid[x][y] & Space.Ink1) {
|
||||||
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
|
image.setAttribute('href', 'assets/InkOverlay.webp');
|
||||||
|
image.setAttribute('x', rect.getAttribute('x')!);
|
||||||
|
image.setAttribute('y', rect.getAttribute('y')!);
|
||||||
|
image.setAttribute('width', rect.getAttribute('width')!);
|
||||||
|
image.setAttribute('height', rect.getAttribute('height')!);
|
||||||
|
gridSvg.appendChild(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cols.push(col);
|
cols.push(col);
|
||||||
}
|
}
|
||||||
|
|
@ -50,19 +69,20 @@ class StageButton extends CheckButton {
|
||||||
|
|
||||||
setStartSpaces(numPlayers: number) {
|
setStartSpaces(numPlayers: number) {
|
||||||
for (const el of this.startCells) {
|
for (const el of this.startCells) {
|
||||||
el[0].setAttribute('class', 'empty');
|
el[0].setAttribute('class', 'Empty');
|
||||||
el[1].parentElement!.removeChild(el[1]);
|
el[1].parentElement!.removeChild(el[1]);
|
||||||
}
|
}
|
||||||
this.startCells.splice(0);
|
this.startCells.splice(0);
|
||||||
|
|
||||||
const startSpaces = this.stage.getStartSpaces(numPlayers);
|
const startSpaces = this.stage.getStartSpaces(numPlayers);
|
||||||
|
if (startSpaces == null) return;
|
||||||
for (let i = 0; i < numPlayers; i++) {
|
for (let i = 0; i < numPlayers; i++) {
|
||||||
const space = startSpaces[i];
|
const space = startSpaces[i];
|
||||||
const cell = this.cells[space.x][space.y]!;
|
const cell = this.cells[space.x][space.y]!;
|
||||||
cell.classList.add(`start${i + 1}`);
|
cell.setAttribute('class', `SpecialInactive${i + 1}`);
|
||||||
|
|
||||||
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
image.setAttribute('href', 'assets/SpecialOverlay.png');
|
image.setAttribute('href', 'assets/SpecialOverlay.webp');
|
||||||
image.setAttribute('x', cell.getAttribute('x')!);
|
image.setAttribute('x', cell.getAttribute('x')!);
|
||||||
image.setAttribute('y', cell.getAttribute('y')!);
|
image.setAttribute('y', cell.getAttribute('y')!);
|
||||||
image.setAttribute('width', cell.getAttribute('width')!);
|
image.setAttribute('width', cell.getAttribute('width')!);
|
||||||
|
|
|
||||||
13
TableturfBattleClient/src/StageSelectionRule.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
enum StageSelectionMethod {
|
||||||
|
Same,
|
||||||
|
Vote,
|
||||||
|
Random,
|
||||||
|
Counterpick,
|
||||||
|
Strike
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StageSelectionRule {
|
||||||
|
method: StageSelectionMethod;
|
||||||
|
bannedStages: number[];
|
||||||
|
strikeCounts: number[];
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ class WinCounter {
|
||||||
readonly parent: HTMLElement;
|
readonly parent: HTMLElement;
|
||||||
private _wins: number = 0;
|
private _wins: number = 0;
|
||||||
|
|
||||||
constructor(element: HTMLDivElement) {
|
constructor(element: HTMLElement) {
|
||||||
this.parent = element;
|
this.parent = element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
declare var baseUrl: string;
|
declare var baseUrl: string;
|
||||||
|
|
||||||
|
const CUSTOM_CARD_START = -10000; // TODO: Card numbers in replays shall be expanded to 2 bytes.
|
||||||
|
const RECEIVED_CUSTOM_CARD_START = -20000;
|
||||||
|
const UNSAVED_CUSTOM_CARD_INDEX = CUSTOM_CARD_START + 1;
|
||||||
const defaultColours = [
|
const defaultColours = [
|
||||||
[ { r: 236, g: 249, b: 1 }, { r: 250, g: 158, b: 0 }, { r: 249, g: 249, b: 31 } ],
|
[ { r: 236, g: 249, b: 1 }, { r: 250, g: 158, b: 0 }, { r: 249, g: 249, b: 31 } ],
|
||||||
[ { r: 74, g: 92, b: 252 }, { r: 1, g: 237, b: 254 }, { r: 213, g: 225, b: 225 } ],
|
[ { r: 74, g: 92, b: 252 }, { r: 1, g: 237, b: 254 }, { r: 213, g: 225, b: 225 } ],
|
||||||
|
|
@ -7,6 +10,7 @@ const defaultColours = [
|
||||||
[ { r: 6, g: 249, b: 148 }, { r: 6, g: 249, b: 6 }, { r: 180, g: 253, b: 199 } ]
|
[ { r: 6, g: 249, b: 148 }, { r: 6, g: 249, b: 6 }, { r: 180, g: 253, b: 199 } ]
|
||||||
];
|
];
|
||||||
let uiBaseColourIsSpecialColourOutOfGame = true;
|
let uiBaseColourIsSpecialColourOutOfGame = true;
|
||||||
|
let uiBaseColourIsSpecialColourPerPlayer = [ true, false, true, true ];
|
||||||
|
|
||||||
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
|
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
|
||||||
const errorMessage = document.getElementById('errorMessage')!;
|
const errorMessage = document.getElementById('errorMessage')!;
|
||||||
|
|
@ -16,7 +20,7 @@ let initialised = false;
|
||||||
let initialiseCallback: (() => void) | null = null;
|
let initialiseCallback: (() => void) | null = null;
|
||||||
let canPushState = isSecureContext && location.protocol != 'file:';
|
let canPushState = isSecureContext && location.protocol != 'file:';
|
||||||
|
|
||||||
const decks = [ new SavedDeck('Starter Deck', 0, [ 6, 34, 159, 13, 45, 137, 22, 52, 141, 28, 55, 103, 40, 56, 92 ], new Array(15), true) ];
|
const decks = [ new SavedDeck('Starter Deck', 0, [ 6, 34, 159, 13, 45, 137, 22, 52, 141, 28, 55, 103, 40, 56, 92 ], new Array(15).fill(1), true) ];
|
||||||
let selectedDeck: SavedDeck | null = null;
|
let selectedDeck: SavedDeck | null = null;
|
||||||
let editingDeck = false;
|
let editingDeck = false;
|
||||||
let deckModified = false;
|
let deckModified = false;
|
||||||
|
|
@ -49,15 +53,21 @@ function onInitialise(callback: () => void) {
|
||||||
|
|
||||||
function initCardDatabase(cards: Card[]) {
|
function initCardDatabase(cards: Card[]) {
|
||||||
deckEditInitCardDatabase(cards);
|
deckEditInitCardDatabase(cards);
|
||||||
|
galleryInitCardDatabase(cards);
|
||||||
|
if (!cards.find(c => c.number < 0)) {
|
||||||
|
gameSetupAllowUpcomingCardsBox.parentElement!.hidden = true;
|
||||||
|
lobbyAllowUpcomingCardsBox.parentElement!.hidden = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function initStageDatabase(stages: Stage[]) {
|
function initStageDatabase(stages: Stage[]) {
|
||||||
|
preGameInitStageDatabase(stages);
|
||||||
lobbyInitStageDatabase(stages);
|
lobbyInitStageDatabase(stages);
|
||||||
deckEditInitStageDatabase(stages);
|
deckEditInitStageDatabase(stages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
const pages = new Map<string, HTMLDivElement>();
|
const pages = new Map<string, HTMLDivElement>();
|
||||||
for (var id of [ 'noJS', 'preGame', 'lobby', 'game', 'deckList', 'deckEdit' ]) {
|
for (var id of [ 'noJS', 'preGame', 'lobby', 'game', 'deckList', 'deckEdit', 'gallery' ]) {
|
||||||
let el = document.getElementById(`${id}Page`) as HTMLDivElement;
|
let el = document.getElementById(`${id}Page`) as HTMLDivElement;
|
||||||
if (!el) throw new EvalError(`Element not found: ${id}Page`);
|
if (!el) throw new EvalError(`Element not found: ${id}Page`);
|
||||||
pages.set(id, el);
|
pages.set(id, el);
|
||||||
|
|
@ -105,29 +115,35 @@ function clearUrlFromGame() {
|
||||||
|
|
||||||
function onGameSettingsChange() {
|
function onGameSettingsChange() {
|
||||||
if (currentGame == null) return;
|
if (currentGame == null) return;
|
||||||
if (lobbyTimeLimitBox.value != currentGame.game.turnTimeLimit?.toString() ?? '')
|
if (lobbyTimeLimitBox.value != (currentGame.game.turnTimeLimit?.toString() ?? ''))
|
||||||
lobbyTimeLimitBox.value = currentGame.game.turnTimeLimit?.toString() ?? '';
|
lobbyTimeLimitBox.value = currentGame.game.turnTimeLimit?.toString() ?? '';
|
||||||
|
lobbyAllowUpcomingCardsBox.checked = currentGame.game.allowUpcomingCards;
|
||||||
|
lobbyAllowCustomCardsBox.checked = currentGame.game.allowCustomCards;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGameStateChange(game: any, playerData: PlayerData | null) {
|
function onGameStateChange(game: any, playerData: PlayerData | null) {
|
||||||
if (currentGame == null)
|
if (currentGame == null)
|
||||||
throw new Error('currentGame is null');
|
throw new Error('currentGame is null');
|
||||||
clearPlayContainers();
|
|
||||||
|
const isSameTurnReconnect = currentGame.game.state == GameState.Ongoing && currentGame.game.turnNumber == turnNumberLabel.turnNumber;
|
||||||
|
|
||||||
|
if (!isSameTurnReconnect) clearPlayContainers();
|
||||||
currentGame.game.state = game.state;
|
currentGame.game.state = game.state;
|
||||||
|
|
||||||
if (game.board) {
|
if (game.board) {
|
||||||
board.flip = playerData != null && playerData.playerIndex % 2 != 0;
|
board.flip = playerData != null && playerData.playerIndex % 2 != 0;
|
||||||
if (board.flip) gamePage.classList.add('boardFlipped');
|
if (board.flip) gamePage.classList.add('boardFlipped');
|
||||||
else gamePage.classList.remove('boardFlipped');
|
else gamePage.classList.remove('boardFlipped');
|
||||||
board.resize(game.board);
|
if (!isSameTurnReconnect) board.resize(game.board);
|
||||||
board.startSpaces = game.startSpaces;
|
board.startSpaces = game.startSpaces;
|
||||||
board.refresh();
|
if (!isSameTurnReconnect) board.refresh();
|
||||||
}
|
}
|
||||||
loadPlayers(game.players);
|
if (currentGame.game.state != GameState.Ongoing || currentGame.game.turnNumber != turnNumberLabel.turnNumber)
|
||||||
|
loadPlayers(game.players);
|
||||||
gamePage.dataset.myPlayerIndex = playerData ? playerData.playerIndex.toString() : '';
|
gamePage.dataset.myPlayerIndex = playerData ? playerData.playerIndex.toString() : '';
|
||||||
gamePage.dataset.uiBaseColourIsSpecialColour = (userConfig.colourLock
|
gamePage.dataset.uiBaseColourIsSpecialColour = (userConfig.colourLock
|
||||||
? (playerData?.playerIndex ?? 0) != 1
|
? (playerData?.playerIndex ?? 0) != 1
|
||||||
: game.players[playerData?.playerIndex ?? 0].uiBaseColourIsSpecialColour ?? true).toString();
|
: game.players[playerData?.playerIndex ?? 0]?.uiBaseColourIsSpecialColour ?? true).toString();
|
||||||
|
|
||||||
if (game.state != GameState.WaitingForPlayers)
|
if (game.state != GameState.WaitingForPlayers)
|
||||||
lobbyLockSettings(true);
|
lobbyLockSettings(true);
|
||||||
|
|
@ -139,16 +155,18 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
|
||||||
case GameState.ChoosingStage:
|
case GameState.ChoosingStage:
|
||||||
initLobbyPage(window.location.toString());
|
initLobbyPage(window.location.toString());
|
||||||
showPage('lobby');
|
showPage('lobby');
|
||||||
|
clearShowDeck();
|
||||||
clearConfirmLeavingGame();
|
clearConfirmLeavingGame();
|
||||||
|
showStageSelectionForm(playerData?.stageSelectionPrompt ?? null, playerData && game.players[playerData.playerIndex]?.isReady);
|
||||||
lobbySelectedStageSection.hidden = true;
|
lobbySelectedStageSection.hidden = true;
|
||||||
lobbyStageSection.hidden = !playerData || game.players[playerData.playerIndex]?.isReady;
|
|
||||||
break;
|
break;
|
||||||
case GameState.ChoosingDeck:
|
case GameState.ChoosingDeck:
|
||||||
showPage('lobby');
|
showPage('lobby');
|
||||||
|
clearShowDeck();
|
||||||
if (currentGame.me) setConfirmLeavingGame();
|
if (currentGame.me) setConfirmLeavingGame();
|
||||||
if (selectedStageIndicator)
|
if (selectedStageIndicator)
|
||||||
lobbySelectedStageSection.removeChild(selectedStageIndicator.buttonElement);
|
lobbySelectedStageSection.removeChild(selectedStageIndicator.buttonElement);
|
||||||
selectedStageIndicator = new StageButton(stageDatabase.stages?.find(s => s.name == game.stage)!);
|
selectedStageIndicator = new StageButton(stageDatabase.stages![game.stage]);
|
||||||
selectedStageIndicator.buttonElement.id = 'selectedStageButton';
|
selectedStageIndicator.buttonElement.id = 'selectedStageButton';
|
||||||
selectedStageIndicator.buttonElement.disabled = true;
|
selectedStageIndicator.buttonElement.disabled = true;
|
||||||
selectedStageIndicator.setStartSpaces(game.players.length);
|
selectedStageIndicator.setStartSpaces(game.players.length);
|
||||||
|
|
@ -161,7 +179,6 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
|
||||||
case GameState.Ongoing:
|
case GameState.Ongoing:
|
||||||
case GameState.GameEnded:
|
case GameState.GameEnded:
|
||||||
case GameState.SetEnded:
|
case GameState.SetEnded:
|
||||||
board.autoHighlight = false;
|
|
||||||
redrawModal.hidden = true;
|
redrawModal.hidden = true;
|
||||||
if (playerData) {
|
if (playerData) {
|
||||||
updateHandAndDeck(playerData);
|
updateHandAndDeck(playerData);
|
||||||
|
|
@ -182,15 +199,17 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
|
||||||
timeLabel.paused = false;
|
timeLabel.paused = false;
|
||||||
break;
|
break;
|
||||||
case GameState.Ongoing:
|
case GameState.Ongoing:
|
||||||
for (let i = 0; i < currentGame.game.players.length; i++)
|
|
||||||
showWaiting(i);
|
|
||||||
if (currentGame.me) setConfirmLeavingGame();
|
if (currentGame.me) setConfirmLeavingGame();
|
||||||
turnNumberLabel.turnNumber = game.turnNumber;
|
turnNumberLabel.turnNumber = game.turnNumber;
|
||||||
board.autoHighlight = true;
|
board.autoHighlight = true;
|
||||||
canPlay = currentGame.me != null && !currentGame.game.players[currentGame.me.playerIndex].isReady;
|
canPlay = currentGame.me != null && !currentGame.game.players[currentGame.me.playerIndex].isReady;
|
||||||
timeLabel.faded = !canPlay;
|
timeLabel.faded = !canPlay;
|
||||||
timeLabel.paused = false;
|
timeLabel.paused = false;
|
||||||
resetPlayControls();
|
if (!isSameTurnReconnect) {
|
||||||
|
for (let i = 0; i < currentGame.game.players.length; i++)
|
||||||
|
showWaiting(i);
|
||||||
|
resetPlayControls();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case GameState.GameEnded:
|
case GameState.GameEnded:
|
||||||
case GameState.SetEnded:
|
case GameState.SetEnded:
|
||||||
|
|
@ -235,7 +254,7 @@ function setupWebSocket(gameID: string) {
|
||||||
const webSocket = new WebSocket(`${config.apiBaseUrl.replace(/(http)(s)?\:\/\//, 'ws$2://')}/websocket?gameID=${gameID}&clientToken=${clientToken}`);
|
const webSocket = new WebSocket(`${config.apiBaseUrl.replace(/(http)(s)?\:\/\//, 'ws$2://')}/websocket?gameID=${gameID}&clientToken=${clientToken}`);
|
||||||
webSocket.addEventListener('open', _ => {
|
webSocket.addEventListener('open', _ => {
|
||||||
enterGameTimeout = setTimeout(() => {
|
enterGameTimeout = setTimeout(() => {
|
||||||
webSocket.close(1002, 'Timeout waiting for a sync message');
|
webSocket.close(1000, 'Timeout waiting for a sync message');
|
||||||
enterGameTimeout = null;
|
enterGameTimeout = null;
|
||||||
communicationError();
|
communicationError();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
@ -251,9 +270,11 @@ function setupWebSocket(gameID: string) {
|
||||||
enterGameTimeout = null;
|
enterGameTimeout = null;
|
||||||
}
|
}
|
||||||
setLoadingMessage(null);
|
setLoadingMessage(null);
|
||||||
if (!e.data) {
|
if (!payload.data) {
|
||||||
|
joinGameError('The game was not found.', false);
|
||||||
|
currentGame = null;
|
||||||
|
webSocket.removeEventListener('close', webSocket_close);
|
||||||
webSocket.close();
|
webSocket.close();
|
||||||
alert('The game was not found.');
|
|
||||||
} else {
|
} else {
|
||||||
currentGame = {
|
currentGame = {
|
||||||
id: gameID,
|
id: gameID,
|
||||||
|
|
@ -265,23 +286,29 @@ function setupWebSocket(gameID: string) {
|
||||||
turnTimeLimit: payload.data.turnTimeLimit,
|
turnTimeLimit: payload.data.turnTimeLimit,
|
||||||
turnTimeLeft: payload.data.turnTimeLeft,
|
turnTimeLeft: payload.data.turnTimeLeft,
|
||||||
goalWinCount: payload.data.goalWinCount,
|
goalWinCount: payload.data.goalWinCount,
|
||||||
|
allowUpcomingCards: payload.data.allowUpcomingCards,
|
||||||
|
allowCustomCards: payload.data.allowCustomCards
|
||||||
},
|
},
|
||||||
me: payload.playerData,
|
me: payload.playerData,
|
||||||
webSocket: webSocket
|
isHost: payload.isHost,
|
||||||
|
webSocket: webSocket,
|
||||||
|
reconnecting: false
|
||||||
};
|
};
|
||||||
updateColours();
|
updateColours();
|
||||||
|
|
||||||
lobbyResetSlots();
|
lobbyResetSlots();
|
||||||
for (let i = 0; i < currentGame.game.players.length; i++)
|
for (let i = 0; i < currentGame.game.players.length; i++)
|
||||||
lobbyAddPlayer(i);
|
lobbyAddPlayer();
|
||||||
onGameSettingsChange();
|
onGameSettingsChange();
|
||||||
|
|
||||||
for (let i = 0; i < playerBars.length; i++) {
|
for (let i = 0; i < playerBars.length; i++) {
|
||||||
playerBars[i].visible = i < currentGame.game.maxPlayers;
|
playerBars[i].visible = i < currentGame.game.maxPlayers;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const button of stageButtons.buttons)
|
for (const button of stageButtons.buttons) {
|
||||||
|
if (!(button instanceof StageButton)) continue;
|
||||||
(button as StageButton).setStartSpaces(currentGame.game.maxPlayers);
|
(button as StageButton).setStartSpaces(currentGame.game.maxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
onGameStateChange(payload.data, payload.playerData);
|
onGameStateChange(payload.data, payload.playerData);
|
||||||
|
|
||||||
|
|
@ -315,18 +342,27 @@ function setupWebSocket(gameID: string) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (currentGame == null) {
|
if (currentGame == null) {
|
||||||
communicationError();
|
if (payload.event != 'playerOnline') communicationError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (payload.event) {
|
switch (payload.event) {
|
||||||
case 'settingsChange':
|
case 'settingsChange':
|
||||||
currentGame.game.turnTimeLimit = payload.data.turnTimeLimit;
|
currentGame.game.turnTimeLimit = payload.data.turnTimeLimit;
|
||||||
|
currentGame.game.allowUpcomingCards = payload.data.allowUpcomingCards;
|
||||||
|
currentGame.game.allowCustomCards = payload.data.allowCustomCards;
|
||||||
onGameSettingsChange();
|
onGameSettingsChange();
|
||||||
break;
|
break;
|
||||||
case 'join':
|
case 'join':
|
||||||
if (payload.data.playerIndex == currentGame.game.players.length) {
|
if (payload.data.playerIndex == currentGame.game.players.length) {
|
||||||
currentGame.game.players.push(payload.data.player);
|
currentGame.game.players.push(payload.data.player);
|
||||||
lobbyAddPlayer(payload.data.playerIndex);
|
lobbyAddPlayer();
|
||||||
|
} else
|
||||||
|
communicationError();
|
||||||
|
break;
|
||||||
|
case 'leave':
|
||||||
|
if (payload.data.playerIndex < currentGame.game.players.length) {
|
||||||
|
currentGame.game.players.splice(payload.data.playerIndex, 1);
|
||||||
|
lobbyRemovePlayer(payload.data.playerIndex);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
communicationError();
|
communicationError();
|
||||||
|
|
@ -336,12 +372,16 @@ function setupWebSocket(gameID: string) {
|
||||||
lobbySetReady(payload.data.playerIndex);
|
lobbySetReady(payload.data.playerIndex);
|
||||||
|
|
||||||
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
|
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
|
||||||
lobbyStageSection.hidden = true;
|
|
||||||
lobbyDeckSection.hidden = true;
|
lobbyDeckSection.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
showReady(payload.data.playerIndex);
|
showReady(payload.data.playerIndex);
|
||||||
break;
|
break;
|
||||||
|
case 'playerOnline':
|
||||||
|
currentGame.game.players[payload.data.playerIndex].isOnline = payload.data.isOnline;
|
||||||
|
lobbySetOnline(payload.data.playerIndex, payload.data.isOnline);
|
||||||
|
playerBars[payload.data.playerIndex].setOnline(payload.data.isOnline);
|
||||||
|
break;
|
||||||
case 'stateChange':
|
case 'stateChange':
|
||||||
clearReady();
|
clearReady();
|
||||||
onGameStateChange(payload.data, payload.playerData);
|
onGameStateChange(payload.data, payload.playerData);
|
||||||
|
|
@ -405,8 +445,12 @@ function setupWebSocket(gameID: string) {
|
||||||
webSocket.addEventListener('close', webSocket_close);
|
webSocket.addEventListener('close', webSocket_close);
|
||||||
}
|
}
|
||||||
|
|
||||||
function webSocket_close() {
|
function webSocket_close(e: CloseEvent) {
|
||||||
communicationError();
|
if (currentGame == null || currentGame.reconnecting || (e.code != 1005 && e.code != 1006))
|
||||||
|
communicationError();
|
||||||
|
else
|
||||||
|
// Try to automatically reconnect.
|
||||||
|
setupWebSocket(currentGame.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConfirmLeavingGame() {
|
function setConfirmLeavingGame() {
|
||||||
|
|
@ -445,11 +489,14 @@ function processUrl() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
swapColours();
|
||||||
stopEditingDeck();
|
stopEditingDeck();
|
||||||
errorDialog.close();
|
errorDialog.close();
|
||||||
clearGame();
|
clearGame();
|
||||||
if (location.pathname.endsWith('/deckeditor') || location.hash == '#deckeditor')
|
if (location.pathname.endsWith('/deckeditor') || location.hash == '#deckeditor')
|
||||||
onInitialise(showDeckList);
|
onInitialise(showDeckList);
|
||||||
|
else if (location.pathname.endsWith('/cardlist') || location.hash == '#cardlist')
|
||||||
|
onInitialise(showCardList);
|
||||||
else {
|
else {
|
||||||
showPage('preGame');
|
showPage('preGame');
|
||||||
if (location.pathname.endsWith('/help') || location.hash == '#help')
|
if (location.pathname.endsWith('/help') || location.hash == '#help')
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ body {
|
||||||
--player-special-accent-colour: var(--special-accent-colour-1);
|
--player-special-accent-colour: var(--special-accent-colour-1);
|
||||||
--theme-colour: #0c92f2;
|
--theme-colour: #0c92f2;
|
||||||
color: white;
|
color: white;
|
||||||
background: url('assets/external/BannerBackground.png') black;
|
background: url('assets/external/BannerBackground.webp') black;
|
||||||
background-position: 50% -72px;
|
background-position: 50% -72px;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
@ -115,11 +115,30 @@ footer {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#stageSwitch button { display: block; }
|
||||||
|
#stageSwitch button[data-status='0'] { color: lime; }
|
||||||
|
#stageSwitch button[data-status='1'] { color: yellow; }
|
||||||
|
#stageSwitch button[data-status='2'] { color: red; }
|
||||||
|
#stageSwitch button * { display: inline-block; }
|
||||||
|
#stageSwitch button .stageName { width: 12em; text-align: start; }
|
||||||
|
#stageSwitch button .stageStatus { width: 8em; text-align: end; }
|
||||||
|
|
||||||
|
option[value='red'] { color: #f2200d; }
|
||||||
|
option[value='orange'] { color: #f2740d; }
|
||||||
|
option[value='yellow'] { color: #ecf901; }
|
||||||
|
option[value='limegreen'] { color: #c0f915; }
|
||||||
|
option[value='green'] { color: #06e006; }
|
||||||
|
option[value='turquoise'] { color: #00ffea; }
|
||||||
|
option[value='blue'] { color: #4a5cfc; }
|
||||||
|
option[value='purple'] { color: #a106ef; }
|
||||||
|
option[value='magenta'] { color: #f906e0; }
|
||||||
|
|
||||||
/* Lobby page */
|
/* Lobby page */
|
||||||
|
|
||||||
#lobbyPage:not([hidden]) {
|
#lobbyPage:not([hidden]) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 27em 1fr;
|
grid-template-columns: 27em 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submitButtonContainer {
|
.submitButtonContainer {
|
||||||
|
|
@ -131,12 +150,21 @@ footer {
|
||||||
#lobbyPlayerListSection {
|
#lobbyPlayerListSection {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lobbySelectedStageSection {
|
||||||
|
grid-row: 2;
|
||||||
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lobbyStageSection, #lobbyDeckSection {
|
#lobbyStageSection, #lobbyDeckSection {
|
||||||
overflow-y: scroll;
|
overflow-y: auto;
|
||||||
height: calc(100vh - 16px);
|
height: calc(100vh - 16px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
grid-column: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playerList {
|
#playerList {
|
||||||
|
|
@ -145,18 +173,35 @@ footer {
|
||||||
}
|
}
|
||||||
|
|
||||||
#playerList li {
|
#playerList li {
|
||||||
width: calc(100% - 3em);
|
width: calc(100% - 2em);
|
||||||
margin: 0.5em 1em;
|
margin: 0.5em 1em;
|
||||||
background: #111;
|
position: relative;
|
||||||
border-radius: 0.5em;
|
}
|
||||||
|
|
||||||
|
#playerList li > div {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background: #111;
|
||||||
text-shadow: 1px 1px black;
|
text-shadow: 1px 1px black;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#playerList li .placeholder {
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playerList .filled {
|
#playerList .filled {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
bottom: 0;
|
||||||
background: var(--theme-colour);
|
background: var(--theme-colour);
|
||||||
position: relative;
|
animation: 0.33s linear forwards playerListFlyIn;
|
||||||
animation: 0.33s linear playerListFlyIn;
|
}
|
||||||
|
|
||||||
|
#playerList .removed {
|
||||||
|
animation: 0.33s linear forwards playerListFlyOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playerList .ready::after {
|
#playerList .ready::after {
|
||||||
|
|
@ -168,11 +213,31 @@ footer {
|
||||||
font-size: x-large;
|
font-size: x-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#playerList .disconnected {
|
||||||
|
color: darkgrey;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes playerListFlyIn {
|
@keyframes playerListFlyIn {
|
||||||
from { left: -100%; }
|
from { left: -120%; }
|
||||||
to { left: 0; }
|
to { left: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes playerListFlyOut {
|
||||||
|
from { left: 0%; }
|
||||||
|
to { left: -120%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnectedIcon {
|
||||||
|
display: none;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected .disconnectedIcon {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.wins {
|
.wins {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +259,10 @@ footer {
|
||||||
margin-right: 1.5em;
|
margin-right: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#lobbyPage label:not([hidden]) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
#lobbyTimeLimitBox {
|
#lobbyTimeLimitBox {
|
||||||
width: 8ch;
|
width: 8ch;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
@ -265,6 +334,12 @@ dialog::backdrop {
|
||||||
.deckButton[data-sleeves="22"] { background-position: 85.7% 63%; }
|
.deckButton[data-sleeves="22"] { background-position: 85.7% 63%; }
|
||||||
.deckButton[data-sleeves="23"] { background-position: 100% 63%; }
|
.deckButton[data-sleeves="23"] { background-position: 100% 63%; }
|
||||||
.deckButton[data-sleeves="24"] { background-position: 0 89%; }
|
.deckButton[data-sleeves="24"] { background-position: 0 89%; }
|
||||||
|
.deckButton[data-sleeves="25"] { background-position: 14.3% 89%; }
|
||||||
|
.deckButton[data-sleeves="26"] { background-position: 28.6% 89%; }
|
||||||
|
.deckButton[data-sleeves="27"] { background-position: 42.9% 89%; }
|
||||||
|
.deckButton[data-sleeves="28"] { background-position: 57.1% 89%; }
|
||||||
|
.deckButton[data-sleeves="29"] { background-position: 71.4% 89%; }
|
||||||
|
.deckButton[data-sleeves="30"] { background-position: 85.7% 89%; }
|
||||||
|
|
||||||
.deckButton:is(:active, .checked) {
|
.deckButton:is(:active, .checked) {
|
||||||
outline-color: lightgrey;
|
outline-color: lightgrey;
|
||||||
|
|
@ -286,7 +361,7 @@ dialog::backdrop {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage, .stageRandom {
|
.stage, .stageRandom:not([hidden]) {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: currentColor; /* Override disabled colour */
|
color: currentColor; /* Override disabled colour */
|
||||||
background: black;
|
background: black;
|
||||||
|
|
@ -299,6 +374,8 @@ dialog::backdrop {
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
}
|
}
|
||||||
|
.stage.banned { display: none; }
|
||||||
|
.stage.struck { opacity: 0.25; }
|
||||||
|
|
||||||
.stageBody {
|
.stageBody {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
@ -318,10 +395,14 @@ dialog::backdrop {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stageGrid rect.start1 { fill: var(--special-colour-1); }
|
.stageGrid rect.Ink1 { fill: var(--primary-colour-1); }
|
||||||
.stageGrid rect.start2 { fill: var(--special-colour-2); }
|
.stageGrid rect.Ink2 { fill: var(--primary-colour-2); }
|
||||||
.stageGrid rect.start3 { fill: var(--special-colour-3); }
|
.stageGrid rect.Ink3 { fill: var(--primary-colour-3); }
|
||||||
.stageGrid rect.start4 { fill: var(--special-colour-4); }
|
.stageGrid rect.Ink4 { fill: var(--primary-colour-4); }
|
||||||
|
.stageGrid rect.SpecialInactive1 { fill: var(--special-colour-1); }
|
||||||
|
.stageGrid rect.SpecialInactive2 { fill: var(--special-colour-2); }
|
||||||
|
.stageGrid rect.SpecialInactive3 { fill: var(--special-colour-3); }
|
||||||
|
.stageGrid rect.SpecialInactive4 { fill: var(--special-colour-4); }
|
||||||
|
|
||||||
:is(.stage, .stageRandom):is(:hover, :focus-within):not(.checked, .disabled)::before {
|
:is(.stage, .stageRandom):is(:hover, :focus-within):not(.checked, .disabled)::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|
@ -375,7 +456,13 @@ dialog::backdrop {
|
||||||
.cardBack[data-sleeves="21"] { background-position: 71.4% 66.7% }
|
.cardBack[data-sleeves="21"] { background-position: 71.4% 66.7% }
|
||||||
.cardBack[data-sleeves="22"] { background-position: 85.7% 66.7% }
|
.cardBack[data-sleeves="22"] { background-position: 85.7% 66.7% }
|
||||||
.cardBack[data-sleeves="23"] { background-position: 100% 66.7% }
|
.cardBack[data-sleeves="23"] { background-position: 100% 66.7% }
|
||||||
.cardBack[data-sleeves="24"] { background-position: 0% 100%; }
|
.cardBack[data-sleeves="24"] { background-position: 0% 66.7% }
|
||||||
|
.cardBack[data-sleeves="25"] { background-position: 14.3% 100% }
|
||||||
|
.cardBack[data-sleeves="26"] { background-position: 28.6% 100% }
|
||||||
|
.cardBack[data-sleeves="27"] { background-position: 42.9% 100% }
|
||||||
|
.cardBack[data-sleeves="28"] { background-position: 57.1% 100% }
|
||||||
|
.cardBack[data-sleeves="29"] { background-position: 71.4% 100% }
|
||||||
|
.cardBack[data-sleeves="30"] { background-position: 85.7% 100% }
|
||||||
|
|
||||||
@keyframes cardBackFadeIn {
|
@keyframes cardBackFadeIn {
|
||||||
from {
|
from {
|
||||||
|
|
@ -430,10 +517,6 @@ dialog::backdrop {
|
||||||
|
|
||||||
.cardNumber {
|
.cardNumber {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
|
||||||
|
|
||||||
.cardListGrid .cardButton:hover .cardNumber {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: grey;
|
background: grey;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
|
|
@ -444,13 +527,17 @@ dialog::backdrop {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardListGrid .cardButton:hover .cardNumber {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.cardName {
|
.cardName {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.25em;
|
line-height: 1.25em;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
.cardButton:is([data-card-number="163"], [data-card-number="166"], [data-card-number="196"], [data-card-number="197"], [data-card-number="199"],
|
.cardButton:is([data-card-number="163"], [data-card-number="166"], [data-card-number="196"], [data-card-number="197"], [data-card-number="199"],
|
||||||
[data-card-number="202"], [data-card-number="216"]) .cardName {
|
[data-card-number="202"], [data-card-number="216"], [data-card-number="-20"]) .cardName {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -1em;
|
left: -1em;
|
||||||
right: -1em;
|
right: -1em;
|
||||||
|
|
@ -506,7 +593,7 @@ dialog::backdrop {
|
||||||
.cardSpecialPoint, .playHintSpecial {
|
.cardSpecialPoint, .playHintSpecial {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
background: url('assets/SpecialOverlay.png') center/cover, var(--player-special-colour);
|
background: url('assets/SpecialOverlay.webp') center/cover, var(--player-special-colour);
|
||||||
width: 1ch;
|
width: 1ch;
|
||||||
height: 1ch;
|
height: 1ch;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
@ -518,10 +605,10 @@ dialog::backdrop {
|
||||||
width: 1.5ch;
|
width: 1.5ch;
|
||||||
height: 1.5ch;
|
height: 1.5ch;
|
||||||
}
|
}
|
||||||
.playHintSpecial:nth-of-type(1):not(:last-of-type) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-1); }
|
.playHintSpecial:nth-of-type(1):not(:last-of-type) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1); }
|
||||||
.playHintSpecial:nth-of-type(2) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-2); }
|
.playHintSpecial:nth-of-type(2) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-2); }
|
||||||
.playHintSpecial:nth-of-type(3) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-3); }
|
.playHintSpecial:nth-of-type(3) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-3); }
|
||||||
.playHintSpecial:nth-of-type(4) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-4); }
|
.playHintSpecial:nth-of-type(4) { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-4); }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -534,6 +621,7 @@ dialog::backdrop {
|
||||||
}
|
}
|
||||||
.playContainer .card {
|
.playContainer .card {
|
||||||
animation: 0.1s ease-out forwards flipCardIn;
|
animation: 0.1s ease-out forwards flipCardIn;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
.playContainer .card.preview {
|
.playContainer .card.preview {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|
@ -560,15 +648,22 @@ svg.card text.cardDisplayName {
|
||||||
transform: scaleX(var(--scale));
|
transform: scaleX(var(--scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
rect.empty {
|
#cardDisplayAssets {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
rect.Empty, rect.empty {
|
||||||
fill: #00000080;
|
fill: #00000080;
|
||||||
stroke: #60606080;
|
stroke: #60606080;
|
||||||
stroke-width: 6;
|
stroke-width: 6;
|
||||||
}
|
}
|
||||||
.stageGrid rect.empty {
|
.stageGrid rect.Empty {
|
||||||
stroke: grey;
|
stroke: grey;
|
||||||
stroke-width: 12;
|
stroke-width: 12;
|
||||||
}
|
}
|
||||||
|
rect.Wall { fill: grey; }
|
||||||
|
|
||||||
rect.ink {
|
rect.ink {
|
||||||
fill: var(--player-primary-colour);
|
fill: var(--player-primary-colour);
|
||||||
|
|
@ -617,19 +712,19 @@ rect.special, g.specialCost rect {
|
||||||
}
|
}
|
||||||
|
|
||||||
#gameBoard td.Empty { background: #000000C0; outline: 1px solid #80808080; outline-offset: -1px; }
|
#gameBoard td.Empty { background: #000000C0; outline: 1px solid #80808080; outline-offset: -1px; }
|
||||||
#gameBoard td.Wall { background: url('assets/Wall.png') center/cover, grey; }
|
#gameBoard td.Wall { background: url('assets/Wall.webp') center/cover, grey; }
|
||||||
#gameBoard td.Ink1 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-1); }
|
#gameBoard td.Ink1 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-1); }
|
||||||
#gameBoard td.Ink2 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-2); }
|
#gameBoard td.Ink2 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-2); }
|
||||||
#gameBoard td.Ink3 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-3); }
|
#gameBoard td.Ink3 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-3); }
|
||||||
#gameBoard td.Ink4 { background: url('assets/InkOverlay.png') center/cover, var(--primary-colour-4); }
|
#gameBoard td.Ink4 { background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-4); }
|
||||||
#gameBoard td.SpecialInactive1 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-1); }
|
#gameBoard td.SpecialInactive1 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1); }
|
||||||
#gameBoard td.SpecialInactive2 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-2); }
|
#gameBoard td.SpecialInactive2 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-2); }
|
||||||
#gameBoard td.SpecialInactive3 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-3); }
|
#gameBoard td.SpecialInactive3 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-3); }
|
||||||
#gameBoard td.SpecialInactive4 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-4); }
|
#gameBoard td.SpecialInactive4 { background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-4); }
|
||||||
#gameBoard td.SpecialActive1 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); }
|
#gameBoard td.SpecialActive1 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); }
|
||||||
#gameBoard td.SpecialActive2 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-2) 25%, var(--special-colour-2) 75%); }
|
#gameBoard td.SpecialActive2 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-2) 25%, var(--special-colour-2) 75%); }
|
||||||
#gameBoard td.SpecialActive3 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-3) 25%, var(--special-colour-3) 75%); }
|
#gameBoard td.SpecialActive3 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-3) 25%, var(--special-colour-3) 75%); }
|
||||||
#gameBoard td.SpecialActive4 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-4) 25%, var(--special-colour-4) 75%); }
|
#gameBoard td.SpecialActive4 { background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour-4) 25%, var(--special-colour-4) 75%); }
|
||||||
|
|
||||||
#gameBoard.specialAttackVisual td:is(.Ink1, .Ink2, .Ink3, .Ink4) {
|
#gameBoard.specialAttackVisual td:is(.Ink1, .Ink2, .Ink3, .Ink4) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
@ -682,6 +777,16 @@ rect.special, g.specialCost rect {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#gameBoard td.testHighlight::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: radial-gradient(#ffffff40, #ffffffc0 75%);
|
||||||
|
}
|
||||||
|
|
||||||
#gameBoard td.hover::after {
|
#gameBoard td.hover::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -704,14 +809,14 @@ rect.special, g.specialCost rect {
|
||||||
transform: rotate(-20deg);
|
transform: rotate(-20deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#gameBoard td.hover1:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-1); }
|
#gamePage[data-my-player-index="0"] #gameBoard td.hover:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-1); }
|
||||||
#gameBoard td.hover2:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-2); }
|
#gamePage[data-my-player-index="1"] #gameBoard td.hover:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-2); }
|
||||||
#gameBoard td.hover3:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-3); }
|
#gamePage[data-my-player-index="2"] #gameBoard td.hover:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-3); }
|
||||||
#gameBoard td.hover4:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-4); }
|
#gamePage[data-my-player-index="3"] #gameBoard td.hover:not(.hoverillegal)::after { --hover-colour: var(--primary-colour-4); }
|
||||||
#gameBoard td.hoverspecial.hover1:not(.hoverillegal)::after { --hover-colour: var(--special-colour-1); }
|
#gamePage[data-my-player-index="0"] #gameBoard td.hoverspecial:not(.hoverillegal)::after { --hover-colour: var(--special-colour-1); }
|
||||||
#gameBoard td.hoverspecial.hover2:not(.hoverillegal)::after { --hover-colour: var(--special-colour-2); }
|
#gamePage[data-my-player-index="1"] #gameBoard td.hoverspecial:not(.hoverillegal)::after { --hover-colour: var(--special-colour-2); }
|
||||||
#gameBoard td.hoverspecial.hover3:not(.hoverillegal)::after { --hover-colour: var(--special-colour-3); }
|
#gamePage[data-my-player-index="2"] #gameBoard td.hoverspecial:not(.hoverillegal)::after { --hover-colour: var(--special-colour-3); }
|
||||||
#gameBoard td.hoverspecial.hover4:not(.hoverillegal)::after { --hover-colour: var(--special-colour-4); }
|
#gamePage[data-my-player-index="3"] #gameBoard td.hoverspecial:not(.hoverillegal)::after { --hover-colour: var(--special-colour-4); }
|
||||||
#gameBoard td.hoverillegal::after { --hover-colour: grey; }
|
#gameBoard td.hoverillegal::after { --hover-colour: grey; }
|
||||||
|
|
||||||
/* Card list */
|
/* Card list */
|
||||||
|
|
@ -719,6 +824,7 @@ rect.special, g.specialCost rect {
|
||||||
#cardList {
|
#cardList {
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
padding-bottom: 4rem;
|
||||||
}
|
}
|
||||||
.cardListControl {
|
.cardListControl {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -726,12 +832,20 @@ rect.special, g.specialCost rect {
|
||||||
}
|
}
|
||||||
.cardListGrid:not([hidden]) {
|
.cardListGrid:not([hidden]) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(10em, 1fr));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#deckEditorRemoveButton {
|
||||||
|
position: absolute;
|
||||||
|
right: 1em;
|
||||||
|
bottom: 1em;
|
||||||
|
font-size: 100%;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Game page */
|
/* Game page */
|
||||||
|
|
||||||
#gamePage:not([hidden]) {
|
#gamePage:not([hidden]) {
|
||||||
|
|
@ -1096,13 +1210,28 @@ rect.special, g.specialCost rect {
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
}
|
}
|
||||||
#testPlacementList div {
|
#testPlacementList button {
|
||||||
background: #222;
|
background: #222;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
text-align: initial;
|
||||||
}
|
}
|
||||||
#testPlacementList div.deckCard {
|
#testPlacementList button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
#testPlacementList button.testHighlight {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
#testPlacementList button.deckCard {
|
||||||
background: #246;
|
background: #246;
|
||||||
}
|
}
|
||||||
|
#testPlacementList button.deckCard:hover {
|
||||||
|
background: #468;
|
||||||
|
}
|
||||||
|
#testPlacementList button.deckCard.testHighlight {
|
||||||
|
background: #579;
|
||||||
|
}
|
||||||
|
|
||||||
#gameButtonsContainer {
|
#gameButtonsContainer {
|
||||||
grid-column: score-column;
|
grid-column: score-column;
|
||||||
|
|
@ -1157,6 +1286,10 @@ rect.special, g.specialCost rect {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disconnected .name {
|
||||||
|
color: darkgrey;
|
||||||
|
}
|
||||||
|
|
||||||
.specialPoints div {
|
.specialPoints div {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1.25em;
|
width: 1.25em;
|
||||||
|
|
@ -1167,10 +1300,10 @@ rect.special, g.specialCost rect {
|
||||||
.playerBar .specialPoint {
|
.playerBar .specialPoint {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour);
|
background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour);
|
||||||
}
|
}
|
||||||
.playerBar .specialPoint.specialAnimation {
|
.playerBar .specialPoint.specialAnimation {
|
||||||
background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour) 25%, var(--special-colour) 75%);
|
background: url('assets/SpecialOverlay.webp') center/cover, radial-gradient(circle, var(--special-accent-colour) 25%, var(--special-colour) 75%);
|
||||||
}
|
}
|
||||||
.playerBar .specialPoint.specialAnimation > div {
|
.playerBar .specialPoint.specialAnimation > div {
|
||||||
background: color-mix(in srgb, var(--special-colour), var(--special-accent-colour) 75%);
|
background: color-mix(in srgb, var(--special-colour), var(--special-accent-colour) 75%);
|
||||||
|
|
@ -1566,6 +1699,7 @@ button.dragging {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
grid-template-rows: auto auto 1fr;
|
grid-template-rows: auto auto 1fr;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#deckViewBackButton, #deckCardListBackButton {
|
#deckViewBackButton, #deckCardListBackButton {
|
||||||
|
|
@ -1577,13 +1711,17 @@ button.dragging {
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#deckEditToolbar, #deckListToolbar {
|
.menuButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
}
|
}
|
||||||
:is(#deckListToolbar, #deckEditToolbar) button {
|
.menu button {
|
||||||
margin: 0.25em;
|
margin: 0.25em;
|
||||||
width: 5em;
|
width: 5em;
|
||||||
height: 4em;
|
height: 4em;
|
||||||
|
|
@ -1636,7 +1774,7 @@ button.dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
#testStageSelectionList {
|
#testStageSelectionList {
|
||||||
max-width: 52em;
|
max-width: 72em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -1655,7 +1793,7 @@ button.dragging {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 80em;
|
max-width: 88em;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1704,6 +1842,193 @@ button.dragging {
|
||||||
#deckSleevesList label:nth-of-type(23) { background-position: -600% -200%; }
|
#deckSleevesList label:nth-of-type(23) { background-position: -600% -200%; }
|
||||||
#deckSleevesList label:nth-of-type(24) { background-position: -700% -200%; }
|
#deckSleevesList label:nth-of-type(24) { background-position: -700% -200%; }
|
||||||
#deckSleevesList label:nth-of-type(25) { background-position: 0% -300%; }
|
#deckSleevesList label:nth-of-type(25) { background-position: 0% -300%; }
|
||||||
|
#deckSleevesList label:nth-of-type(26) { background-position: -100% -300%; }
|
||||||
|
#deckSleevesList label:nth-of-type(27) { background-position: -200% -300%; }
|
||||||
|
#deckSleevesList label:nth-of-type(28) { background-position: -300% -300%; }
|
||||||
|
#deckSleevesList label:nth-of-type(29) { background-position: -400% -300%; }
|
||||||
|
#deckSleevesList label:nth-of-type(30) { background-position: -500% -300%; }
|
||||||
|
#deckSleevesList label:nth-of-type(31) { background-position: -600% -300%; }
|
||||||
|
|
||||||
|
/* Card list */
|
||||||
|
|
||||||
|
#galleryPage:not([hidden]) {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryPage > header {
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, 10em);
|
||||||
|
grid-auto-rows: auto;
|
||||||
|
gap: 0.5em;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardList .card {
|
||||||
|
height: 14rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardList .card.unowned svg {
|
||||||
|
opacity: 0.333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardList .card:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardList .card:hover .cardNumber {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardDialog > .card {
|
||||||
|
height: min(75vh, 100vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditor {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
font-size: calc(min(75vh, 100vw) * 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorProperties {
|
||||||
|
position: absolute;
|
||||||
|
left: 5%;
|
||||||
|
top: 3%;
|
||||||
|
font: 33% 'Splatoon 2', sans-serif;
|
||||||
|
background: grey;
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorName {
|
||||||
|
position: absolute;
|
||||||
|
left: 5%;
|
||||||
|
top: 8%;
|
||||||
|
width: 90%;
|
||||||
|
height: 20%;
|
||||||
|
color: black;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.25em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.common #galleryCardEditorName { color: rgb(89, 49, 255); }
|
||||||
|
.card.rare #galleryCardEditorName { color: rgb(231, 180, 39); }
|
||||||
|
.card.fresh #galleryCardEditorName { color: white; }
|
||||||
|
|
||||||
|
.card.editing :is(.cardDisplayName, .cardGrid) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorGrid {
|
||||||
|
position: absolute;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
top: 25%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
grid-template-rows: repeat(8, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorGrid button {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
:is(#galleryCardEditorGrid, #galleryCardEditorSpecialCost) button:is(:hover, :focus)::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #ffffff80;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorGrid button[data-state="0"] {
|
||||||
|
border: 1px solid grey;
|
||||||
|
background: #00000080;
|
||||||
|
}
|
||||||
|
#galleryCardEditorGrid button[data-state="4"] {
|
||||||
|
border: 1px solid grey;
|
||||||
|
background: url('assets/InkOverlay.webp') center/cover, var(--primary-colour-1);
|
||||||
|
}
|
||||||
|
#galleryCardEditorGrid button[data-state="8"] {
|
||||||
|
border: 1px solid grey;
|
||||||
|
background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.galleryCardEditorToolbar {
|
||||||
|
position: absolute;
|
||||||
|
right: 5%;
|
||||||
|
font: 33% 'Splatoon 2', sans-serif;
|
||||||
|
background: grey;
|
||||||
|
padding: 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.galleryCardEditorToolbar footer {
|
||||||
|
font-size: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorImageToolbar { bottom: 18%; }
|
||||||
|
#galleryCardEditorColoursToolbar { bottom: 12%; }
|
||||||
|
#galleryCardEditorRarityToolbar { bottom: 6%; }
|
||||||
|
|
||||||
|
#galleryCardEditorImageFile { display: none; }
|
||||||
|
|
||||||
|
#galleryCardEditorColoursToolbar input {
|
||||||
|
width: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorColourPresetBox {
|
||||||
|
width: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorSpecialCost {
|
||||||
|
display: grid;
|
||||||
|
position: absolute;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
left: 26.8%;
|
||||||
|
top: 86.2%;
|
||||||
|
gap: 0.15em 0.075em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorSpecialCost button {
|
||||||
|
width: 2.5vh;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: none;
|
||||||
|
background: #00000080;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#galleryCardEditorSpecialCost button.active {
|
||||||
|
background: url('assets/SpecialOverlay.webp') center/cover, var(--special-colour-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#galleryCardEditorSpecialCost label {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 105%;
|
||||||
|
font: 33% 'Splatoon 2', sans-serif;
|
||||||
|
background: grey;
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Help */
|
/* Help */
|
||||||
|
|
||||||
|
|
@ -1762,6 +2087,8 @@ button.dragging {
|
||||||
@media (max-width: 40rem) {
|
@media (max-width: 40rem) {
|
||||||
.cardButton {
|
.cardButton {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
width: auto;
|
||||||
|
justify-self: stretch;
|
||||||
}
|
}
|
||||||
.cardListGrid:not([hidden]) {
|
.cardListGrid:not([hidden]) {
|
||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
|
|
@ -1775,12 +2102,24 @@ button.dragging {
|
||||||
|
|
||||||
#lobbyPage:not([hidden]) {
|
#lobbyPage:not([hidden]) {
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
grid-template-columns: initial;
|
grid-template-columns: 1fr auto;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lobbyStageSection, #lobbyDeckSection {
|
#lobbyStageSection, #lobbyDeckSection {
|
||||||
height: initial;
|
height: initial;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lobbySelectedStageSection {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 2;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lobbySelectedStageSection .stage {
|
||||||
|
font-size: 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stageList, #testStageSelectionList {
|
#stageList, #testStageSelectionList {
|
||||||
|
|
@ -1891,19 +2230,19 @@ button.dragging {
|
||||||
top: auto;
|
top: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.playContainer[data-index="0"] { grid-row: 2 / span 2; justify-content: start; margin: 0 0 2em 1em; }
|
.playContainer[data-index="0"] { grid-row: 2 / span 2; justify-self: start; margin: 0 0 2em 1em; }
|
||||||
.playContainer[data-index="1"] { grid-row: 1 / span 2; justify-content: end; margin: 4em 1em 0 0; }
|
.playContainer[data-index="1"] { grid-row: 1 / span 2; justify-self: end; margin: 4em 0 0 0; }
|
||||||
.playContainer[data-index="2"] { grid-row: 2 / span 2; justify-content: end; margin: 0 1em 2em 0; }
|
.playContainer[data-index="2"] { grid-row: 2 / span 2; justify-self: end; margin: 0 0 2em 0; }
|
||||||
.playContainer[data-index="3"] { grid-row: 1 / span 2; justify-content: start; margin: 4em 0 0 1em; }
|
.playContainer[data-index="3"] { grid-row: 1 / span 2; justify-self: start; margin: 4em 0 0 1em; }
|
||||||
#gamePage[data-players="2"] .playContainer[data-index="0"] { justify-content: end; margin: 0 1em 0 0; }
|
#gamePage[data-players="2"] .playContainer[data-index="0"] { justify-self: end; margin: 0; }
|
||||||
#gamePage[data-players="2"] .playContainer[data-index="1"] { justify-content: end; margin: 0 1em 0 0; }
|
#gamePage[data-players="2"] .playContainer[data-index="1"] { justify-self: end; margin: 0; }
|
||||||
|
|
||||||
#gamePage.boardFlipped .playContainer[data-index="1"] { grid-row: 2 / span 2; justify-content: start; margin: 0 0 2em 1em; }
|
#gamePage.boardFlipped .playContainer[data-index="1"] { grid-row: 2 / span 2; justify-self: start; margin: 0 0 2em 1em; }
|
||||||
#gamePage.boardFlipped .playContainer[data-index="0"] { grid-row: 1 / span 2; justify-content: end; margin: 2em 1em 0 0; }
|
#gamePage.boardFlipped .playContainer[data-index="0"] { grid-row: 1 / span 2; justify-self: end; margin: 2em 0 0 0; }
|
||||||
#gamePage.boardFlipped .playContainer[data-index="3"] { grid-row: 2 / span 2; justify-content: end; margin: 0 1em 2em 0; }
|
#gamePage.boardFlipped .playContainer[data-index="3"] { grid-row: 2 / span 2; justify-self: end; margin: 0 0 2em 0; }
|
||||||
#gamePage.boardFlipped .playContainer[data-index="2"] { grid-row: 1 / span 2; justify-content: start; margin: 2em 0 0 1em; }
|
#gamePage.boardFlipped .playContainer[data-index="2"] { grid-row: 1 / span 2; justify-self: start; margin: 2em 0 0 1em; }
|
||||||
#gamePage.boardFlipped[data-players="2"] .playContainer[data-index="0"] { justify-content: end; margin: 0 1em 0 0; }
|
#gamePage.boardFlipped[data-players="2"] .playContainer[data-index="0"] { justify-self: end; margin: 0; }
|
||||||
#gamePage.boardFlipped[data-players="2"] .playContainer[data-index="1"] { justify-content: end; margin: 0 1em 0 0; }
|
#gamePage.boardFlipped[data-players="2"] .playContainer[data-index="1"] { justify-self: end; margin: 0; }
|
||||||
|
|
||||||
#gameButtonsContainer {
|
#gameButtonsContainer {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
|
|
@ -1955,32 +2294,69 @@ button.dragging {
|
||||||
|
|
||||||
#deckCardListBackButton {
|
#deckCardListBackButton {
|
||||||
display: block;
|
display: block;
|
||||||
grid-column: 1;
|
flex-grow: 1;
|
||||||
grid-row: 1;
|
}
|
||||||
|
|
||||||
|
#deckName, #deckName2 {
|
||||||
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#deckName {
|
#deckName {
|
||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#deckListToolbar, #deckEditToolbar {
|
#cardListHeader h3 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuButton {
|
||||||
|
display: block;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: -2;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.menuButton svg {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
grid-column: 1 / -2;
|
grid-column: 1 / -2;
|
||||||
flex-flow: row;
|
flex-flow: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(#deckListToolbar, #deckEditToolbar) button {
|
.menu:not(.showing) {
|
||||||
width: auto;
|
display: none;
|
||||||
flex-grow: 1;
|
}
|
||||||
flex-basis: 4em;
|
|
||||||
|
.menu.showing {
|
||||||
|
grid-row: 2 / -1;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
background: #000000c0;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
grid-column: -2;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-self: end;
|
||||||
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deckSizeContainer {
|
.deckSizeContainer {
|
||||||
grid-column: -2;
|
grid-column: -2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
padding: 0.25em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sizeLabel { display: none; }
|
||||||
|
|
||||||
#deckCardListView, #deckCardListEdit {
|
#deckCardListView, #deckCardListEdit {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
|
|
@ -1988,6 +2364,7 @@ button.dragging {
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#deckEditorCardListSection:not(.selecting) {
|
#deckEditorCardListSection:not(.selecting) {
|
||||||
|
|
@ -2008,10 +2385,6 @@ button.dragging {
|
||||||
font-size: 55%;
|
font-size: 55%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cardList .cardButton {
|
|
||||||
height: 13em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#testControls:not([hidden]) {
|
#testControls:not([hidden]) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: -2;
|
grid-row: -2;
|
||||||
|
|
|
||||||
2
TableturfBattleServer/.vscode/launch.json
vendored
|
|
@ -10,7 +10,7 @@
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
// If you have changed target frameworks, make sure to update the program path.
|
// If you have changed target frameworks, make sure to update the program path.
|
||||||
"program": "${workspaceFolder}/bin/Debug/net6.0/TableturfBattleServer.dll",
|
"program": "${workspaceFolder}/bin/Debug/net8.0/TableturfBattleServer.dll",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||||
|
|
|
||||||
14
TableturfBattleServer/ApiEndpointAttribute.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
namespace TableturfBattleServer;
|
||||||
|
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||||
|
internal class ApiEndpointAttribute(ApiEndpointNamespace endpointNamespace, string path, string allowedMethod) : Attribute {
|
||||||
|
public ApiEndpointNamespace Namespace { get; } = endpointNamespace;
|
||||||
|
public string Path { get; } = path;
|
||||||
|
public string AllowedMethod { get; } = allowedMethod;
|
||||||
|
|
||||||
|
public ApiEndpointAttribute(string path, string allowedMethod) : this(ApiEndpointNamespace.ApiRoot, path, allowedMethod) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum ApiEndpointNamespace {
|
||||||
|
ApiRoot,
|
||||||
|
Game
|
||||||
|
}
|
||||||
529
TableturfBattleServer/ApiEndpoints.cs
Normal file
|
|
@ -0,0 +1,529 @@
|
||||||
|
#pragma warning disable IDE0060 // Remove unused parameter
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using TableturfBattleServer.DTO;
|
||||||
|
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
|
||||||
|
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
|
||||||
|
|
||||||
|
namespace TableturfBattleServer;
|
||||||
|
internal static class ApiEndpoints {
|
||||||
|
internal static readonly char[] DELIMITERS = [',', ' '];
|
||||||
|
internal const int CUSTOM_CARD_START = -10000;
|
||||||
|
internal const int RECEIVED_CUSTOM_CARD_START = -20000;
|
||||||
|
|
||||||
|
[ApiEndpoint("/games/new", "POST")]
|
||||||
|
public static void ApiGamesNew(HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
if (request.HttpMethod != "POST") {
|
||||||
|
response.AddHeader("Allow", "POST");
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
||||||
|
} else if (Server.Instance.Lockdown) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon."));
|
||||||
|
} else if (request.ContentLength64 >= 65536) {
|
||||||
|
response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
Guid clientToken;
|
||||||
|
if (!d.TryGetValue("name", out var name)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name.Length > 32) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var maxPlayers = 2;
|
||||||
|
if (d.TryGetValue("maxPlayers", out var maxPlayersString)) {
|
||||||
|
if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidMaxPlayers", "Invalid player limit."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int? turnTimeLimit = null;
|
||||||
|
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString) && turnTimeLimitString != "") {
|
||||||
|
if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
turnTimeLimit = turnTimeLimit2;
|
||||||
|
}
|
||||||
|
int? goalWinCount = null;
|
||||||
|
if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") {
|
||||||
|
if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goalWinCount = goalWinCount2;
|
||||||
|
}
|
||||||
|
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
|
||||||
|
if (!Guid.TryParse(tokenString, out clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
clientToken = Guid.NewGuid();
|
||||||
|
|
||||||
|
bool allowUpcomingCards;
|
||||||
|
if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) {
|
||||||
|
if (!bool.TryParse(allowUpcomingCardsString, out allowUpcomingCards))
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowUpcomingCards was invalid."));
|
||||||
|
} else
|
||||||
|
allowUpcomingCards = true;
|
||||||
|
|
||||||
|
bool allowCustomCards;
|
||||||
|
if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) {
|
||||||
|
if (!bool.TryParse(allowCustomCardsString, out allowCustomCards))
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowCustomCards was invalid."));
|
||||||
|
} else
|
||||||
|
allowCustomCards = false;
|
||||||
|
|
||||||
|
StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null;
|
||||||
|
if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) {
|
||||||
|
if (!HttpRequestHelper.TryParseStageSelectionRule(json1, maxPlayers, out stageSelectionRuleFirst) || stageSelectionRuleFirst.Method is StageSelectionMethod.Same or StageSelectionMethod.Counterpick) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleFirst was invalid."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
stageSelectionRuleFirst = StageSelectionRules.Default;
|
||||||
|
if (d.TryGetValue("stageSelectionRuleAfterWin", out var json2)) {
|
||||||
|
if (!HttpRequestHelper.TryParseStageSelectionRule(json2, maxPlayers, out stageSelectionRuleAfterWin)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterWin was invalid."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
stageSelectionRuleAfterWin = stageSelectionRuleFirst;
|
||||||
|
if (d.TryGetValue("stageSelectionRuleAfterDraw", out var json3)) {
|
||||||
|
if (!HttpRequestHelper.TryParseStageSelectionRule(json3, maxPlayers, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterDraw was invalid."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
stageSelectionRuleAfterDraw = stageSelectionRuleFirst;
|
||||||
|
|
||||||
|
var forceSameDeckAfterDraw = false;
|
||||||
|
if (d.TryGetValue("forceSameDeckAfterDraw", out var forceSameDeckAfterDrawString)) {
|
||||||
|
if (!bool.TryParse(forceSameDeckAfterDrawString, out forceSameDeckAfterDraw))
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "forceSameDeckAfterDraw was invalid."));
|
||||||
|
} else
|
||||||
|
forceSameDeckAfterDraw = false;
|
||||||
|
|
||||||
|
var spectate = false;
|
||||||
|
if (d.TryGetValue("spectate", out var spectateString)) {
|
||||||
|
if (!bool.TryParse(spectateString, out spectate))
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "spectate was invalid."));
|
||||||
|
} else
|
||||||
|
spectate = false;
|
||||||
|
|
||||||
|
var game = new Game(maxPlayers) { HostClientToken = clientToken, GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, AllowUpcomingCards = allowUpcomingCards, AllowCustomCards = allowCustomCards, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw };
|
||||||
|
if (!spectate)
|
||||||
|
game.TryAddPlayer(new(game, name, clientToken), out _, out _);
|
||||||
|
Server.Instance.games.Add(game.ID, game);
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
|
||||||
|
response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers }));
|
||||||
|
Console.WriteLine($"New game started: {game.ID}; {Server.Instance.games.Count} games active; {Server.Instance.inactiveGames.Count} inactive");
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint("/cards", "GET")]
|
||||||
|
public static void ApiCards(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
=> HttpRequestHelper.SetStaticResponse(request, response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified);
|
||||||
|
|
||||||
|
[ApiEndpoint("/stages", "GET")]
|
||||||
|
public static void ApiStages(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
=> HttpRequestHelper.SetStaticResponse(request, response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified);
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/", "GET")]
|
||||||
|
public static void ApiGameRoot(Game game, HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
=> response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game));
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/playerData", "GET")]
|
||||||
|
public static void ApiGamePlayerData(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
if (request.QueryString["clientToken"] is not string s || !Guid.TryParse(s, out var clientToken))
|
||||||
|
clientToken = Guid.Empty;
|
||||||
|
|
||||||
|
response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new {
|
||||||
|
game,
|
||||||
|
playerData = game.GetPlayer(clientToken, out var playerIndex, out var player)
|
||||||
|
? new PlayerData(playerIndex, player)
|
||||||
|
: null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/join", "POST")]
|
||||||
|
public static void ApiGameJoin(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
try {
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
Guid clientToken;
|
||||||
|
if (!d.TryGetValue("name", out var name)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name.Length > 32) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
|
||||||
|
if (!Guid.TryParse(tokenString, out clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
clientToken = Guid.NewGuid();
|
||||||
|
|
||||||
|
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
||||||
|
if (game.State != GameState.WaitingForPlayers) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player = new Player(game, name, clientToken);
|
||||||
|
if (!game.TryAddPlayer(player, out playerIndex, out var error)) {
|
||||||
|
response.SetErrorResponse(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.SendEvent("join", new { playerIndex, player }, false);
|
||||||
|
}
|
||||||
|
// If they're already in the game, resend the original join response instead of an error.
|
||||||
|
response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken }));
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/setGameSettings", "POST")]
|
||||||
|
public static void ApiGameSetGameSettings(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clientToken != game.HostClientToken) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (game.State != GameState.WaitingForPlayers) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) {
|
||||||
|
if (turnTimeLimitString == "")
|
||||||
|
game.TurnTimeLimit = null;
|
||||||
|
else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid turn time limit."));
|
||||||
|
return;
|
||||||
|
} else
|
||||||
|
game.TurnTimeLimit = turnTimeLimit2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) {
|
||||||
|
if (!bool.TryParse(allowUpcomingCardsString, out var allowUpcomingCards)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowUpcomingCards."));
|
||||||
|
return;
|
||||||
|
} else
|
||||||
|
game.AllowUpcomingCards = allowUpcomingCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) {
|
||||||
|
if (!bool.TryParse(allowCustomCardsString, out var allowCustomCards)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowCustomCards."));
|
||||||
|
return;
|
||||||
|
} else
|
||||||
|
game.AllowCustomCards = allowCustomCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.SendEvent("settingsChange", game, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/chooseStage", "POST")]
|
||||||
|
public static void ApiGameChooseStage(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!d.TryGetValue("stages", out var stagesString)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stages = new HashSet<int>();
|
||||||
|
foreach (var field in stagesString.Split(DELIMITERS, StringSplitOptions.RemoveEmptyEntries)) {
|
||||||
|
if (!int.TryParse(field, out var i)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stages.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game.TryChooseStages(player, stages, out var error)) {
|
||||||
|
response.SetErrorResponse(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NoContent;
|
||||||
|
game.SendPlayerReadyEvent(playerIndex, false);
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/chooseDeck", "POST")]
|
||||||
|
public static void ApiGameChooseDeck(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
try {
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (player.CurrentGameData.Deck != null) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.TryGetValue("deckName", out var deckName)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var deckSleeves = 0;
|
||||||
|
if (d.TryGetValue("deckSleeves", out var deckSleevesString) && !int.TryParse(deckSleevesString, out deckSleeves)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!d.TryGetValue("deckCards", out var deckString)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, UserCustomCard>? userCustomCards = null;
|
||||||
|
List<KeyValuePair<int, Card>>? customCardsToAdd = null;
|
||||||
|
if (d.TryGetValue("customCards", out var customCardsString)) {
|
||||||
|
if (!game.AllowCustomCards) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "CustomCardsNotAllowed", "Custom cards cannot be used in this game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userCustomCards = JsonUtils.Deserialise<Dictionary<int, UserCustomCard>>(customCardsString);
|
||||||
|
|
||||||
|
// Validate custom cards.
|
||||||
|
if (userCustomCards is null || userCustomCards.Count > 15 || userCustomCards.Keys.Any(k => k is not (<= -10000 and >= short.MinValue))) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", "Invalid custom cards."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
customCardsToAdd = new(userCustomCards.Count);
|
||||||
|
foreach (var (k, v) in userCustomCards) {
|
||||||
|
if (!v.CheckGrid(out var hasSpecialSpace, out var size)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Custom card {k} is invalid."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Allow resending the same custom card, but not a different custom card with the same key.
|
||||||
|
if (player.customCardMap.TryGetValue(k, out var existingCustomCardNumber)) {
|
||||||
|
if (!v.Equals(game.customCards[RECEIVED_CUSTOM_CARD_START - existingCustomCardNumber])) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Cannot reuse custom card number {k}."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: Consolidate identical custom cards brought by different players.
|
||||||
|
var card = v.ToCard(RECEIVED_CUSTOM_CARD_START - (game.customCards.Count + customCardsToAdd.Count), k, !hasSpecialSpace && size >= 8 ? 3 : null);
|
||||||
|
customCardsToAdd.Add(new(k, card));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var array = deckString.Split([',', '+', ' '], 15);
|
||||||
|
if (array.Length != 15) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int[]? upgrades = null;
|
||||||
|
if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) {
|
||||||
|
upgrades = new int[15];
|
||||||
|
var array2 = deckUpgradesString.Split([',', '+', ' '], 15);
|
||||||
|
for (var i = 0; i < 15; i++) {
|
||||||
|
if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2)
|
||||||
|
upgrades[i] = j;
|
||||||
|
else {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cards = new int[15];
|
||||||
|
for (var i = 0; i < 15; i++) {
|
||||||
|
if (!int.TryParse(array[i], out var cardNumber) || (!CardDatabase.IsValidCardNumber(cardNumber) && (userCustomCards == null || !userCustomCards.ContainsKey(cardNumber)))) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!game.AllowUpcomingCards && cardNumber is < 0 and > CUSTOM_CARD_START && CardDatabase.GetCard(cardNumber).Number < 0) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "ForbiddenDeck", "Upcoming cards cannot be used in this game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Translate custom card numbers from the player to game-scoped card numbers.
|
||||||
|
cards[i] = player.customCardMap.TryGetValue(cardNumber, out var n) ? n : cardNumber <= CUSTOM_CARD_START && customCardsToAdd?.FirstOrDefault(e => e.Key == cardNumber).Value is Card customCard ? customCard.Number : cardNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customCardsToAdd != null) {
|
||||||
|
foreach (var (userKey, card) in customCardsToAdd) {
|
||||||
|
player.customCardMap.Add(userKey, card.Number);
|
||||||
|
game.customCards.Add(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15));
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NoContent;
|
||||||
|
game.SendPlayerReadyEvent(playerIndex, false);
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/play", "POST")]
|
||||||
|
public static void ApiGamePlay(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
try {
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (game.State != GameState.Ongoing) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.Move != null) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var handIndex = player.GetHandIndex(cardNumber);
|
||||||
|
if (handIndex < 0) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "MissingCard", "You don't have that card."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isTimeout = d.TryGetValue("isTimeout", out var isTimeoutStr) && isTimeoutStr.ToLower() is not ("false" or "0");
|
||||||
|
|
||||||
|
var card = player.Hand![handIndex];
|
||||||
|
if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) {
|
||||||
|
player.Move = new(card, true, 0, 0, 0, false, isTimeout);
|
||||||
|
} else {
|
||||||
|
var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0");
|
||||||
|
if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x)
|
||||||
|
|| !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y)
|
||||||
|
|| !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r &= 3;
|
||||||
|
if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout);
|
||||||
|
}
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NoContent;
|
||||||
|
game.SendPlayerReadyEvent(playerIndex, isTimeout);
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
response.StatusCode = (int) HttpStatusCode.BadRequest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/redraw", "POST")]
|
||||||
|
public static void ApiGameRedraw(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
try {
|
||||||
|
if (game.State != GameState.Redraw) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.Move != null) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0");
|
||||||
|
player.Move = new(player.Hand![0], false, 0, 0, 0, redraw, false);
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NoContent;
|
||||||
|
game.SendPlayerReadyEvent(playerIndex, false);
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
response.StatusCode = (int) HttpStatusCode.BadRequest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/nextGame", "POST")]
|
||||||
|
public static void ApiGameNextGame(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
try {
|
||||||
|
if (game.State is not (GameState.GameEnded or GameState.SetEnded)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var d = HttpRequestHelper.DecodeFormData(request.InputStream);
|
||||||
|
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.Move == null) {
|
||||||
|
player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready.
|
||||||
|
game.SendPlayerReadyEvent(playerIndex, false);
|
||||||
|
}
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NoContent;
|
||||||
|
Server.Instance.timer.Start();
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
response.StatusCode = (int) HttpStatusCode.BadRequest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiEndpoint(ApiEndpointNamespace.Game, "/replay", "GET")]
|
||||||
|
public static void ApiGameReplay(Game game, HttpListenerRequest request, HttpListenerResponse response) {
|
||||||
|
if (game.State != GameState.SetEnded) {
|
||||||
|
response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
game.WriteReplayData(ms);
|
||||||
|
response.SetResponse(HttpStatusCode.OK, "application/octet-stream", ms.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,35 +9,35 @@ public class Card {
|
||||||
public int SpecialCost { get; }
|
public int SpecialCost { get; }
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public int Size { get; }
|
public int Size { get; }
|
||||||
|
public int? IsVariantOf { get; init; }
|
||||||
|
|
||||||
public string? Line1 { get; init; }
|
public string Line1 { get; init; }
|
||||||
public string? Line2 { get; init; }
|
public string? Line2 { get; init; }
|
||||||
public string? ArtFileName { get; init; }
|
public string? ArtFileName { get; init; }
|
||||||
public float TextScale { get; init; }
|
|
||||||
public Colour? InkColour1 { get; init; }
|
public Colour? InkColour1 { get; init; }
|
||||||
public Colour? InkColour2 { get; init; }
|
public Colour? InkColour2 { get; init; }
|
||||||
|
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
private readonly Space[,] grid;
|
private readonly Space[,] grid;
|
||||||
|
|
||||||
internal Card(int number, string name, Rarity rarity, float textScale, string? artFileName, Space[,] grid) : this(number, null, name, rarity, null, textScale, artFileName, grid) { }
|
internal Card(int number, string name, Rarity rarity, string? artFileName, Space[,] grid) : this(number, null, name, rarity, null, artFileName, grid) { }
|
||||||
internal Card(int number, int? altNumber, string name, Rarity rarity, float textScale, string? artFileName, Space[,] grid) : this(number, altNumber, name, rarity, null, textScale, artFileName, grid) { }
|
internal Card(int number, int? altNumber, string name, Rarity rarity, string? artFileName, Space[,] grid) : this(number, altNumber, name, rarity, null, artFileName, grid) { }
|
||||||
internal Card(int number, string name, Rarity rarity, int? specialCost, float textScale, string? artFileName, Space[,] grid) : this(number, null, name, rarity, specialCost, textScale, artFileName, grid) { }
|
internal Card(int number, string name, Rarity rarity, int? specialCost, string? artFileName, Space[,] grid) : this(number, null, name, rarity, specialCost, artFileName, grid) { }
|
||||||
internal Card(int number, int? altNumber, string name, Rarity rarity, int? specialCost, float textScale, string? artFileName, Space[,] grid) {
|
internal Card(int number, int? altNumber, string name, Rarity rarity, int? specialCost, string? artFileName, Space[,] grid) {
|
||||||
this.Number = number;
|
this.Number = number;
|
||||||
this.AltNumber = altNumber;
|
this.AltNumber = altNumber;
|
||||||
this.Rarity = rarity;
|
this.Rarity = rarity;
|
||||||
this.TextScale = textScale;
|
|
||||||
this.ArtFileName = artFileName;
|
this.ArtFileName = artFileName;
|
||||||
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));
|
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));
|
||||||
|
|
||||||
var pos = (name ?? throw new ArgumentNullException(nameof(name))).IndexOf('\n');
|
var pos = (name ?? throw new ArgumentNullException(nameof(name))).IndexOf('\n');
|
||||||
if (pos < 0)
|
if (pos < 0) {
|
||||||
this.Name = name;
|
this.Name = name;
|
||||||
else {
|
this.Line1 = name;
|
||||||
|
} else {
|
||||||
this.Name = name[pos - 1] == '-' ? name.Remove(pos, 1) : name.Replace('\n', ' ');
|
this.Name = name[pos - 1] == '-' ? name.Remove(pos, 1) : name.Replace('\n', ' ');
|
||||||
this.Line1 = name[0..pos];
|
this.Line1 = name[0..pos];
|
||||||
this.Line2 = name[(pos + 1)..];
|
this.Line2 = name[(pos + 1)..];
|
||||||
}
|
}
|
||||||
|
|
||||||
var size = 0;
|
var size = 0;
|
||||||
|
|
@ -58,7 +58,6 @@ public class Card {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.Size = size;
|
this.Size = size;
|
||||||
|
|
||||||
this.SpecialCost = specialCost ?? size switch { <= 3 => 1, <= 5 => 2, <= 8 => 3, <= 11 => 4, <= 15 => 5, _ => 6 };
|
this.SpecialCost = specialCost ?? size switch { <= 3 => 1, <= 5 => 2, <= 8 => 3, <= 11 => 4, <= 15 => 5, _ => 6 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,64 @@
|
||||||
|
|
||||||
namespace TableturfBattleServer.DTO;
|
namespace TableturfBattleServer.DTO;
|
||||||
|
|
||||||
internal class WebSocketPayload<T> {
|
internal class WebSocketPayload<T>(string eventName, T payload) {
|
||||||
[JsonProperty("event")]
|
[JsonProperty("event")]
|
||||||
public string EventName;
|
public string EventName = eventName ?? throw new ArgumentNullException(nameof(eventName));
|
||||||
[JsonProperty("data")]
|
[JsonProperty("data")]
|
||||||
public T Payload;
|
public T Payload = payload;
|
||||||
|
}
|
||||||
|
internal class WebSocketPayloadWithPlayerData<T>(string eventName, T payload, PlayerData? playerData, bool isHost) : WebSocketPayload<T>(eventName, payload) {
|
||||||
|
public PlayerData? PlayerData = playerData;
|
||||||
|
public bool IsHost = isHost;
|
||||||
|
}
|
||||||
|
|
||||||
public WebSocketPayload(string eventName, T payload) {
|
public class PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List<int>? cardsUsed, StageSelectionPrompt? stageSelectionPrompt) {
|
||||||
this.EventName = eventName ?? throw new ArgumentNullException(nameof(eventName));
|
public int PlayerIndex = playerIndex;
|
||||||
this.Payload = payload;
|
public Card[]? Hand = hand;
|
||||||
|
public Deck? Deck = deck;
|
||||||
|
public Move? Move = move;
|
||||||
|
public List<int>? CardsUsed = cardsUsed;
|
||||||
|
public StageSelectionPrompt? StageSelectionPrompt = stageSelectionPrompt;
|
||||||
|
|
||||||
|
public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed, player.StageSelectionPrompt) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserCustomCard(string Name, string? Line1, string? Line2, int? SpecialCost, Colour InkColour1, Colour InkColour2, Rarity Rarity, Space[,] Grid) {
|
||||||
|
public bool CheckGrid(out bool hasSpecialSpace, out int size) {
|
||||||
|
size = 0;
|
||||||
|
hasSpecialSpace = false;
|
||||||
|
if (this.Grid is null || this.Grid.GetLength(0) != 8 || this.Grid.GetLength(1) != 8) return false;
|
||||||
|
for (var x = 0; x < 8; x++) {
|
||||||
|
for (var y = 0; y < 8; y++) {
|
||||||
|
switch (this.Grid[x, y]) {
|
||||||
|
case Space.Empty:
|
||||||
|
break;
|
||||||
|
case Space.Ink1:
|
||||||
|
size++;
|
||||||
|
break;
|
||||||
|
case Space.SpecialInactive1:
|
||||||
|
if (hasSpecialSpace) return false;
|
||||||
|
size++;
|
||||||
|
hasSpecialSpace = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Maybe also check that the ink pattern is fully connected.
|
||||||
|
return size > 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
internal class WebSocketPayloadWithPlayerData<T> : WebSocketPayload<T> {
|
|
||||||
public PlayerData? PlayerData;
|
|
||||||
|
|
||||||
public WebSocketPayloadWithPlayerData(string eventName, T payload, PlayerData? playerData) : base(eventName, payload)
|
public bool Equals(Card? card) {
|
||||||
=> this.PlayerData = playerData;
|
if (card is null || card.Name != this.Name || card.Rarity != this.Rarity || card.SpecialCost != this.SpecialCost) return false;
|
||||||
}
|
for (var x = 0; x < 8; x++) {
|
||||||
|
for (var y = 0; y < 8; y++) {
|
||||||
public class PlayerData {
|
if (this.Grid[x, y] != card.GetSpace(x, y, 0)) return false;
|
||||||
public int PlayerIndex;
|
}
|
||||||
public Card[]? Hand;
|
}
|
||||||
public Deck? Deck;
|
return true;
|
||||||
public Move? Move;
|
|
||||||
public List<int>? CardsUsed;
|
|
||||||
|
|
||||||
public PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List<int>? cardsUsed) {
|
|
||||||
this.PlayerIndex = playerIndex;
|
|
||||||
this.Hand = hand;
|
|
||||||
this.Deck = deck;
|
|
||||||
this.Move = move;
|
|
||||||
this.CardsUsed = cardsUsed;
|
|
||||||
}
|
}
|
||||||
public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed) { }
|
|
||||||
|
public Card ToCard(int number, int altNumber, int? defaultSpecialCost) => new(number, altNumber, this.Line2 != null ? $"{this.Line1}\n{this.Line2}" : this.Name, this.Rarity, this.SpecialCost ?? defaultSpecialCost, null, this.Grid) { InkColour1 = this.InkColour1, InkColour2 = this.InkColour2 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public class Deck : IEquatable<Deck> {
|
public class Deck(string name, int sleeves, Card[] cards, int[] levels) : IEquatable<Deck> {
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
internal string Name;
|
internal string Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
internal int Sleeves;
|
internal int Sleeves = sleeves;
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
internal Card[] Cards;
|
internal Card[] Cards = cards ?? throw new ArgumentNullException(nameof(cards));
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
internal int[] Upgrades;
|
internal int[] Upgrades = levels ?? throw new ArgumentNullException(nameof(levels));
|
||||||
|
|
||||||
public Deck(string name, int sleeves, Card[] cards, int[] levels) {
|
|
||||||
this.Name = name ?? throw new ArgumentNullException(nameof(name));
|
|
||||||
this.Sleeves = sleeves;
|
|
||||||
this.Cards = cards ?? throw new ArgumentNullException(nameof(cards));
|
|
||||||
this.Upgrades = levels ?? throw new ArgumentNullException(nameof(levels));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Equals(Deck? other)
|
public bool Equals(Deck? other)
|
||||||
=> other is not null && this.Name == other.Name && this.Sleeves == other.Sleeves && this.Cards.SequenceEqual(other.Cards) && this.Upgrades.SequenceEqual(other.Upgrades);
|
=> other is not null && this.Name == other.Name && this.Sleeves == other.Sleeves && this.Cards.SequenceEqual(other.Cards) && this.Upgrades.SequenceEqual(other.Upgrades);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public struct Error {
|
public readonly struct Error(HttpStatusCode httpStatusCode, string code, string description) {
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public HttpStatusCode HttpStatusCode { get; }
|
public HttpStatusCode HttpStatusCode { get; } = httpStatusCode;
|
||||||
public string Code { get; }
|
public string Code { get; } = code ?? throw new ArgumentNullException(nameof(code));
|
||||||
public string Description { get; }
|
public string Description { get; } = description ?? throw new ArgumentNullException(nameof(description));
|
||||||
|
|
||||||
public Error(HttpStatusCode httpStatusCode, string code, string description) {
|
|
||||||
this.HttpStatusCode = httpStatusCode;
|
|
||||||
this.Code = code ?? throw new ArgumentNullException(nameof(code));
|
|
||||||
this.Description = description ?? throw new ArgumentNullException(nameof(description));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public class Game {
|
public class Game(int maxPlayers) {
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Guid ID { get; } = Guid.NewGuid();
|
public Guid ID { get; } = Guid.NewGuid();
|
||||||
|
|
||||||
public GameState State { get; set; }
|
public GameState State { get; set; }
|
||||||
public int TurnNumber { get; set; }
|
public int TurnNumber { get; set; }
|
||||||
public List<Player> Players { get; } = new(4);
|
public List<Player> Players { get; } = new(4);
|
||||||
public int MaxPlayers { get; set; }
|
[JsonIgnore]
|
||||||
|
internal Guid HostClientToken { get; set; }
|
||||||
|
public int MaxPlayers { get; set; } = maxPlayers;
|
||||||
[JsonProperty("stage")]
|
[JsonProperty("stage")]
|
||||||
public string? StageName { get; private set; }
|
public int? StageIndex { get; private set; }
|
||||||
public Space[,]? Board { get; private set; }
|
public Space[,]? Board { get; private set; }
|
||||||
public Point[]? StartSpaces;
|
public Point[]? StartSpaces;
|
||||||
|
|
||||||
|
|
@ -25,14 +26,24 @@ public class Game {
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal DateTime abandonedSince = DateTime.UtcNow;
|
internal DateTime abandonedSince = DateTime.UtcNow;
|
||||||
|
|
||||||
[JsonIgnore]
|
public bool AllowUpcomingCards { get; set; } = true;
|
||||||
internal List<Deck> deckCache = new();
|
public bool AllowCustomCards { get; set; }
|
||||||
[JsonIgnore]
|
|
||||||
internal List<string> setStages = new();
|
|
||||||
|
|
||||||
public Game(int maxPlayers) => this.MaxPlayers = maxPlayers;
|
public required StageSelectionRules StageSelectionRuleFirst { get; set; }
|
||||||
|
public required StageSelectionRules StageSelectionRuleAfterWin { get; set; }
|
||||||
|
public required StageSelectionRules StageSelectionRuleAfterDraw { get; set; }
|
||||||
|
public bool ForceSameDeckAfterDraw { get; set; }
|
||||||
|
|
||||||
private static readonly PlayerColours[] Colours = new PlayerColours[] {
|
public List<int> StruckStages = [];
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal List<Deck> deckCache = [];
|
||||||
|
[JsonIgnore]
|
||||||
|
internal List<Card> customCards = [];
|
||||||
|
[JsonIgnore]
|
||||||
|
internal List<int> setStages = [];
|
||||||
|
|
||||||
|
private static readonly PlayerColours[] Colours = [
|
||||||
new(new(0xf2200d), new(0xff8c1a), new(0xffd5cc), false), // Red
|
new(new(0xf2200d), new(0xff8c1a), new(0xffd5cc), false), // Red
|
||||||
new(new(0xf2740d), new(0xff4000), new(0xffcc99), true), // Orange
|
new(new(0xf2740d), new(0xff4000), new(0xffcc99), true), // Orange
|
||||||
new(new(0xecf901), new(0xfa9e00), new(0xf9f91f), true), // Yellow
|
new(new(0xecf901), new(0xfa9e00), new(0xf9f91f), true), // Yellow
|
||||||
|
|
@ -42,7 +53,7 @@ public class Game {
|
||||||
new(new(0x4a5cfc), new(0x01edfe), new(0xd5e1e1), false), // Blue
|
new(new(0x4a5cfc), new(0x01edfe), new(0xd5e1e1), false), // Blue
|
||||||
new(new(0xa106ef), new(0xff00ff), new(0xffb3ff), false), // Purple
|
new(new(0xa106ef), new(0xff00ff), new(0xffb3ff), false), // Purple
|
||||||
new(new(0xf906e0), new(0x8006f9), new(0xebb4fd), true), // Magenta
|
new(new(0xf906e0), new(0x8006f9), new(0xebb4fd), true), // Magenta
|
||||||
};
|
];
|
||||||
|
|
||||||
public bool TryAddPlayer(Player player, out int playerIndex, out Error error) {
|
public bool TryAddPlayer(Player player, out int playerIndex, out Error error) {
|
||||||
lock (this.Players) {
|
lock (this.Players) {
|
||||||
|
|
@ -63,6 +74,13 @@ public class Game {
|
||||||
}
|
}
|
||||||
playerIndex = this.Players.Count;
|
playerIndex = this.Players.Count;
|
||||||
this.Players.Add(player);
|
this.Players.Add(player);
|
||||||
|
|
||||||
|
player.StageSelectionPrompt = this.StageSelectionRuleFirst.Method switch {
|
||||||
|
StageSelectionMethod.Vote => new() { PromptType = StageSelectionPromptType.Vote, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty<int>() },
|
||||||
|
StageSelectionMethod.Strike => new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty<int>() },
|
||||||
|
_ => new() { PromptType = StageSelectionPromptType.Wait, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty<int>() }
|
||||||
|
};
|
||||||
|
|
||||||
error = default;
|
error = default;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -82,17 +100,51 @@ public class Game {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryChooseStages(Player player, ICollection<int> stages, out Error error) {
|
||||||
|
if (player.StageSelectionPrompt == null || player.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.Wait) {
|
||||||
|
error = new(HttpStatusCode.Conflict, "CannotChooseStage", "You cannot choose stages now.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (player.selectedStages != null) {
|
||||||
|
error = new(HttpStatusCode.Conflict, "StageAlreadyChosen", "You've already chosen a stage.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (player.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.VoteOrder) {
|
||||||
|
if (stages.Count != 1) {
|
||||||
|
error = new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stage selection.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (stages.Any(i => i >= StageDatabase.Stages.Count)) {
|
||||||
|
error = new(HttpStatusCode.UnprocessableEntity, "InvalidStage", "Invalid stage selection.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var rule = this.GetCurrentStageSelectionRule();
|
||||||
|
if (stages.Intersect(rule.BannedStages).Any()) {
|
||||||
|
error = new(HttpStatusCode.UnprocessableEntity, "IllegalStage", "A selected stage is banned.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (player.StageSelectionPrompt.Value.StruckStages != null && stages.Intersect(player.StageSelectionPrompt.Value.StruckStages).Any()) { // Includes stages previously won on when counterpicking.
|
||||||
|
error = new(HttpStatusCode.UnprocessableEntity, "IllegalStage", "A selected stage was struck.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.selectedStages = stages;
|
||||||
|
error = default;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public Deck GetDeck(string name, int sleeves, IEnumerable<int> cardNumbers, IEnumerable<int> cardUpgrades) {
|
public Deck GetDeck(string name, int sleeves, IEnumerable<int> cardNumbers, IEnumerable<int> cardUpgrades) {
|
||||||
var deck = this.deckCache.FirstOrDefault(d => d.Name == name && d.Sleeves == sleeves && cardNumbers.SequenceEqual(from c in d.Cards select c.Number) && cardUpgrades.SequenceEqual(d.Upgrades));
|
var deck = this.deckCache.FirstOrDefault(d => d.Name == name && d.Sleeves == sleeves && cardNumbers.SequenceEqual(from c in d.Cards select c.Number) && cardUpgrades.SequenceEqual(d.Upgrades));
|
||||||
if (deck == null) {
|
if (deck == null) {
|
||||||
deck = new(name, sleeves, (from i in cardNumbers select CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray());
|
deck = new(name, sleeves, (from i in cardNumbers select i <= ApiEndpoints.RECEIVED_CUSTOM_CARD_START ? customCards[ApiEndpoints.RECEIVED_CUSTOM_CARD_START - i] : CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray());
|
||||||
this.deckCache.Add(deck);
|
this.deckCache.Add(deck);
|
||||||
}
|
}
|
||||||
return deck;
|
return deck;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanPlay(int playerIndex, Card card, int x, int y, int rotation, bool isSpecialAttack) {
|
public bool CanPlay(int playerIndex, Card card, int x, int y, int rotation, bool isSpecialAttack) {
|
||||||
if (card is null) throw new ArgumentNullException(nameof(card));
|
ArgumentNullException.ThrowIfNull(card);
|
||||||
if (this.Board is null || this.Players[playerIndex].CurrentGameData is not SingleGameData gameData) return false;
|
if (this.Board is null || this.Players[playerIndex].CurrentGameData is not SingleGameData gameData) return false;
|
||||||
|
|
||||||
if (isSpecialAttack && (gameData.SpecialPoints < card.SpecialCost))
|
if (isSpecialAttack && (gameData.SpecialPoints < card.SpecialCost))
|
||||||
|
|
@ -109,7 +161,8 @@ public class Game {
|
||||||
|| y2 < 0 || y2 > this.Board.GetUpperBound(1))
|
|| y2 < 0 || y2 > this.Board.GetUpperBound(1))
|
||||||
return false; // Out of bounds.
|
return false; // Out of bounds.
|
||||||
switch (this.Board[x2, y2]) {
|
switch (this.Board[x2, y2]) {
|
||||||
case Space.Wall: case Space.OutOfBounds:
|
case Space.Wall:
|
||||||
|
case Space.OutOfBounds:
|
||||||
return false;
|
return false;
|
||||||
case >= Space.SpecialInactive1:
|
case >= Space.SpecialInactive1:
|
||||||
return false; // Can't overlap special spaces ever.
|
return false; // Can't overlap special spaces ever.
|
||||||
|
|
@ -143,11 +196,18 @@ public class Game {
|
||||||
return isAnchored;
|
return isAnchored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private StageSelectionRules GetCurrentStageSelectionRule() {
|
||||||
|
return this.Players.Count == 0 || this.Players[0].Games.Count <= 1
|
||||||
|
? this.StageSelectionRuleFirst
|
||||||
|
: this.Players.Any(p => p.Games[^2].won) ? this.StageSelectionRuleAfterWin : this.StageSelectionRuleAfterDraw;
|
||||||
|
}
|
||||||
|
|
||||||
internal void Tick() {
|
internal void Tick() {
|
||||||
if (this.State is GameState.WaitingForPlayers or GameState.ChoosingStage && this.Players.Count >= 2 && this.Players.All(p => p.selectedStageIndex != null)) {
|
if (this.State is GameState.WaitingForPlayers or GameState.ChoosingStage && this.Players.Count >= 2 && this.Players.All(p => p.StageSelectionPrompt == null || p.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.Wait || p.selectedStages != null)) {
|
||||||
// Choose colours.
|
// Choose colours.
|
||||||
var random = new Random();
|
var random = new Random();
|
||||||
if (this.State == GameState.WaitingForPlayers) {
|
if (this.State == GameState.WaitingForPlayers) {
|
||||||
|
this.State = GameState.ChoosingStage;
|
||||||
var index = random.Next(Colours.Length);
|
var index = random.Next(Colours.Length);
|
||||||
var increment = this.Players.Count switch {
|
var increment = this.Players.Count switch {
|
||||||
2 => random.Next(3, 7),
|
2 => random.Next(3, 7),
|
||||||
|
|
@ -165,29 +225,88 @@ public class Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose the stage.
|
// Choose the stage.
|
||||||
var stageIndex = this.Players[random.Next(this.Players.Count)].selectedStageIndex!.Value;
|
var rule = this.GetCurrentStageSelectionRule();
|
||||||
if (stageIndex < 0) stageIndex = random.Next(StageDatabase.Stages.Count);
|
switch (rule.Method) {
|
||||||
var stage = StageDatabase.Stages[stageIndex];
|
case StageSelectionMethod.Vote: {
|
||||||
this.StageName = stage.Name;
|
var stageIndex = this.Players[random.Next(this.Players.Count)].selectedStages!.First();
|
||||||
this.setStages.Add(stage.Name);
|
if (stageIndex < 0) stageIndex = random.Next(StageDatabase.Stages.Count);
|
||||||
this.Board = (Space[,]) stage.grid.Clone();
|
this.LockInStage(stageIndex);
|
||||||
|
this.SendEvent("stateChange", this, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StageSelectionMethod.Random: {
|
||||||
|
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList();
|
||||||
|
var stageIndex = legalStages[random.Next(legalStages.Count)];
|
||||||
|
this.LockInStage(stageIndex);
|
||||||
|
this.SendEvent("stateChange", this, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StageSelectionMethod.Counterpick: {
|
||||||
|
var player = this.Players.FirstOrDefault(p => p.StageSelectionPrompt != null && p.StageSelectionPrompt?.PromptType != StageSelectionPromptType.Wait);
|
||||||
|
if (player == null) {
|
||||||
|
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList();
|
||||||
|
this.LockInStage(legalStages[random.Next(legalStages.Count)]);
|
||||||
|
} else {
|
||||||
|
if (player.selectedStages!.First() >= 0)
|
||||||
|
this.LockInStage(player.selectedStages!.First());
|
||||||
|
else {
|
||||||
|
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(player.StageSelectionPrompt!.Value.StruckStages).ToList();
|
||||||
|
this.LockInStage(legalStages[random.Next(legalStages.Count)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.SendEvent("stateChange", this, true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StageSelectionMethod.Strike: {
|
||||||
|
var player = this.Players.FirstOrDefault(p => p.StageSelectionPrompt != null && p.StageSelectionPrompt?.PromptType != StageSelectionPromptType.Wait) ?? throw new InvalidOperationException("Couldn't find striking player?!");
|
||||||
|
switch (player.StageSelectionPrompt!.Value.PromptType) {
|
||||||
|
case StageSelectionPromptType.VoteOrder:
|
||||||
|
// Choose who will strike first.
|
||||||
|
Player? firstPlayer = null;
|
||||||
|
foreach (var player2 in this.Players) {
|
||||||
|
if (player2.selectedStages!.First() == 0) {
|
||||||
|
if (firstPlayer == null || random.Next(2) == 0)
|
||||||
|
firstPlayer = player2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
firstPlayer ??= this.Players[random.Next(this.Players.Count)];
|
||||||
|
// Present new prompts.
|
||||||
|
foreach (var player2 in this.Players) {
|
||||||
|
player2.StageSelectionPrompt = new() { PromptType = player2 == firstPlayer ? StageSelectionPromptType.Strike : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, NumberOfStagesToStrike = 1 };
|
||||||
|
player2.selectedStages = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case StageSelectionPromptType.Strike:
|
||||||
|
this.StruckStages.AddRange(player.selectedStages!);
|
||||||
|
var index = this.Players.IndexOf(player);
|
||||||
|
index = (index + 1) % this.Players.Count;
|
||||||
|
|
||||||
// Place starting positions.
|
// Present new prompts.
|
||||||
var list = stage.startSpaces.Where(s => s.Length >= this.Players.Count).MinBy(s => s.Length) ?? throw new InvalidOperationException("Couldn't find start spaces");
|
for (var i = 0; i < this.Players.Count; i++) {
|
||||||
this.StartSpaces = list;
|
this.Players[i].StageSelectionPrompt = i == index
|
||||||
for (int i = 0; i < this.Players.Count; i++)
|
? this.StruckStages.Count == 2 || Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(this.StruckStages).Count() <= 3
|
||||||
this.Board[list[i].X, list[i].Y] = Space.SpecialInactive1 | (Space) i;
|
? new() { PromptType = StageSelectionPromptType.Choose, BannedStages = rule.BannedStages, StruckStages = this.StruckStages }
|
||||||
|
: new() { PromptType = StageSelectionPromptType.Strike, BannedStages = rule.BannedStages, StruckStages = this.StruckStages, NumberOfStagesToStrike = 2 }
|
||||||
this.State = GameState.ChoosingDeck;
|
: new() { PromptType = StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = this.StruckStages };
|
||||||
this.SendEvent("stateChange", this, true);
|
}
|
||||||
|
break;
|
||||||
|
case StageSelectionPromptType.Choose:
|
||||||
|
if (player.selectedStages!.First() >= 0)
|
||||||
|
this.LockInStage(player.selectedStages!.First());
|
||||||
|
else {
|
||||||
|
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(player.StageSelectionPrompt!.Value.StruckStages).ToList();
|
||||||
|
this.LockInStage(legalStages[random.Next(legalStages.Count)]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.SendEvent("stateChange", this, true);
|
||||||
|
foreach (var player2 in this.Players)
|
||||||
|
player2.selectedStages = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (this.State == GameState.ChoosingDeck && this.Players.All(p => p.CurrentGameData.Deck != null)) {
|
} else if (this.State == GameState.ChoosingDeck && this.Players.All(p => p.CurrentGameData.Deck != null)) {
|
||||||
// Draw cards.
|
this.StartGame();
|
||||||
var random = new Random();
|
|
||||||
foreach (var player in this.Players)
|
|
||||||
player.Shuffle(random);
|
|
||||||
|
|
||||||
this.State = GameState.Redraw;
|
|
||||||
this.TurnTimeLeft = this.TurnTimeLimit;
|
|
||||||
this.SendEvent("stateChange", this, true);
|
this.SendEvent("stateChange", this, true);
|
||||||
} else if (this.State == GameState.Redraw && this.Players.All(p => p.Move != null)) {
|
} else if (this.State == GameState.Redraw && this.Players.All(p => p.Move != null)) {
|
||||||
var random = new Random();
|
var random = new Random();
|
||||||
|
|
@ -347,7 +466,7 @@ public class Game {
|
||||||
}
|
}
|
||||||
} else if (this.TurnTimeLeft != null) {
|
} else if (this.TurnTimeLeft != null) {
|
||||||
--this.TurnTimeLeft;
|
--this.TurnTimeLeft;
|
||||||
if (this.TurnTimeLeft <= -3) { // Add a small grace period to account for network lag.
|
if (this.TurnTimeLeft <= -3 || (this.TurnTimeLeft <= 0 && this.Players.All(p => p.IsReady || !p.IsOnline))) { // Add a small grace period to account for network lag for online players.
|
||||||
for (var i = 0; i < this.Players.Count; i++) {
|
for (var i = 0; i < this.Players.Count; i++) {
|
||||||
var player = this.Players[i];
|
var player = this.Players[i];
|
||||||
if (player.Move == null) {
|
if (player.Move == null) {
|
||||||
|
|
@ -368,21 +487,141 @@ public class Game {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.State is GameState.GameEnded or GameState.SetEnded && this.Players.All(p => p.Move != null)) {
|
} else if (this.State is GameState.GameEnded or GameState.SetEnded && this.Players.All(p => p.Move != null)) {
|
||||||
foreach (var player in this.Players) {
|
this.SetupNextGame();
|
||||||
player.selectedStageIndex = null;
|
|
||||||
player.Hand = null;
|
|
||||||
player.CardsUsed.Clear();
|
|
||||||
player.Games.Add(new());
|
|
||||||
player.ClearMoves();
|
|
||||||
}
|
|
||||||
this.State = GameState.ChoosingStage;
|
|
||||||
this.TurnTimeLeft = this.TurnTimeLimit;
|
|
||||||
this.SendEvent("stateChange", this, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LockInStage(int stageIndex) {
|
||||||
|
var stage = StageDatabase.Stages[stageIndex];
|
||||||
|
this.StageIndex = stageIndex;
|
||||||
|
this.setStages.Add(stageIndex);
|
||||||
|
this.Board = (Space[,]) stage.Grid.Clone();
|
||||||
|
|
||||||
|
// Place starting positions.
|
||||||
|
var list = stage.StartSpaces.Where(s => s.Length >= this.Players.Count).MinBy(s => s.Length) ?? throw new InvalidOperationException("Couldn't find start spaces");
|
||||||
|
this.StartSpaces = list;
|
||||||
|
for (int i = 0; i < this.Players.Count; i++)
|
||||||
|
this.Board[list[i].X, list[i].Y] = Space.SpecialInactive1 | (Space) i;
|
||||||
|
|
||||||
|
foreach (var player in this.Players) {
|
||||||
|
player.StageSelectionPrompt = null;
|
||||||
|
player.selectedStages = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ForceSameDeckAfterDraw && this.Players[0].Games.Count > 1 && !this.Players.Any(p => p.WonLastGame)) {
|
||||||
|
foreach (var player in this.Players)
|
||||||
|
player.CurrentGameData.Deck = player.Games[^2].Deck;
|
||||||
|
this.StartGame();
|
||||||
|
} else
|
||||||
|
this.State = GameState.ChoosingDeck;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartGame() {
|
||||||
|
// Draw cards.
|
||||||
|
var random = new Random();
|
||||||
|
foreach (var player in this.Players)
|
||||||
|
player.Shuffle(random);
|
||||||
|
|
||||||
|
this.State = GameState.Redraw;
|
||||||
|
this.TurnTimeLeft = this.TurnTimeLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupNextGame() {
|
||||||
|
this.State = GameState.ChoosingStage;
|
||||||
|
this.StruckStages.Clear();
|
||||||
|
this.TurnTimeLeft = this.TurnTimeLimit;
|
||||||
|
|
||||||
|
var winner = this.Players.FirstOrDefault(p => p.CurrentGameData.won);
|
||||||
|
|
||||||
|
foreach (var player in this.Players) {
|
||||||
|
player.selectedStages = null;
|
||||||
|
player.Hand = null;
|
||||||
|
player.CardsUsed.Clear();
|
||||||
|
player.ClearMoves();
|
||||||
|
player.Games.Add(new());
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule = this.GetCurrentStageSelectionRule();
|
||||||
|
var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList();
|
||||||
|
switch (rule.Method) {
|
||||||
|
case StageSelectionMethod.Same:
|
||||||
|
this.LockInStage(this.setStages[^1]);
|
||||||
|
break;
|
||||||
|
case StageSelectionMethod.Vote: {
|
||||||
|
if (legalStages.Count == 1) {
|
||||||
|
this.LockInStage(legalStages[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var bannedStages = legalStages.Count == 0 ? [] : rule.BannedStages;
|
||||||
|
foreach (var player in this.Players) {
|
||||||
|
player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.Vote, BannedStages = bannedStages, StruckStages = [] };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StageSelectionMethod.Random: {
|
||||||
|
var random = new Random();
|
||||||
|
var stage = legalStages.Count switch {
|
||||||
|
0 => random.Next(StageDatabase.Stages.Count),
|
||||||
|
1 => legalStages[0],
|
||||||
|
_ => legalStages[random.Next(legalStages.Count)]
|
||||||
|
};
|
||||||
|
this.LockInStage(stage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StageSelectionMethod.Counterpick: {
|
||||||
|
var playerPicking = this.Players.FirstOrDefault(p => p != winner) ?? this.Players[0]; // Should never reach the latter case.
|
||||||
|
|
||||||
|
// Prevent picking stages that the player has previously won on if that would leave any legal stages.
|
||||||
|
var struckStages = new HashSet<int>();
|
||||||
|
for (var i = 0; i < playerPicking.Games.Count; i++) {
|
||||||
|
if (playerPicking.Games[i].won && !rule.BannedStages.Contains(this.setStages[i]))
|
||||||
|
struckStages.Add(this.setStages[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var player in this.Players)
|
||||||
|
player.StageSelectionPrompt = new() { PromptType = player == playerPicking ? StageSelectionPromptType.Choose : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = struckStages };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StageSelectionMethod.Strike: {
|
||||||
|
if (winner == null) {
|
||||||
|
foreach (var player in this.Players)
|
||||||
|
player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = rule.BannedStages, StruckStages = Array.Empty<int>() };
|
||||||
|
} else {
|
||||||
|
// After a win, the winner strikes first.
|
||||||
|
foreach (var player in this.Players)
|
||||||
|
player.StageSelectionPrompt = new() { PromptType = player == winner ? StageSelectionPromptType.Strike : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = Array.Empty<int>(), NumberOfStagesToStrike = 2 };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.SendEvent("stateChange", this, true);
|
||||||
|
}
|
||||||
|
|
||||||
internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) => this.SendEvent("playerReady", new { playerIndex, isTimeout }, false);
|
internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) => this.SendEvent("playerReady", new { playerIndex, isTimeout }, false);
|
||||||
|
|
||||||
|
internal void AddConnection(int playerIndex, TableturfWebSocketBehaviour connection) {
|
||||||
|
var player = this.Players[playerIndex];
|
||||||
|
player.AddConnection(connection);
|
||||||
|
if (!player.IsOnline) {
|
||||||
|
player.DisconnectedAt = null;
|
||||||
|
this.SendEvent("playerOnline", new { playerIndex, player.IsOnline }, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal void RemoveConnection(Player player, TableturfWebSocketBehaviour connection) {
|
||||||
|
var playerIndex = this.Players.IndexOf(player);
|
||||||
|
player.RemoveConnection(connection);
|
||||||
|
if (player.IsOnline && player.Connections.Count == 0) {
|
||||||
|
if (this.State == GameState.WaitingForPlayers) {
|
||||||
|
this.Players.RemoveAt(playerIndex);
|
||||||
|
this.SendEvent("leave", new { playerIndex }, false);
|
||||||
|
} else {
|
||||||
|
player.DisconnectedAt = DateTime.UtcNow;
|
||||||
|
this.SendEvent("playerOnline", new { playerIndex, player.IsOnline }, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal void SendEvent<T>(string eventType, T data, bool includePlayerData) {
|
internal void SendEvent<T>(string eventType, T data, bool includePlayerData) {
|
||||||
foreach (var session in Program.httpServer!.WebSocketServices.Hosts.First().Sessions.Sessions) {
|
foreach (var session in Program.httpServer!.WebSocketServices.Hosts.First().Sessions.Sessions) {
|
||||||
if (session is TableturfWebSocketBehaviour behaviour && behaviour.GameID == this.ID) {
|
if (session is TableturfWebSocketBehaviour behaviour && behaviour.GameID == this.ID) {
|
||||||
|
|
@ -395,7 +634,7 @@ public class Game {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<T>(eventType, data, playerData)));
|
behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<T>(eventType, data, playerData, behaviour.ClientToken == this.HostClientToken)));
|
||||||
} else {
|
} else {
|
||||||
behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayload<T>(eventType, data)));
|
behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayload<T>(eventType, data)));
|
||||||
}
|
}
|
||||||
|
|
@ -404,7 +643,7 @@ public class Game {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WriteReplayData(Stream stream) {
|
public void WriteReplayData(Stream stream) {
|
||||||
const int VERSION = 3;
|
const int VERSION = 5;
|
||||||
|
|
||||||
if (this.State < GameState.SetEnded)
|
if (this.State < GameState.SetEnded)
|
||||||
throw new InvalidOperationException("Can't save a replay until the set has ended.");
|
throw new InvalidOperationException("Can't save a replay until the set has ended.");
|
||||||
|
|
@ -430,13 +669,32 @@ public class Game {
|
||||||
writer.Write(nameBytes);
|
writer.Write(nameBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom cards
|
||||||
|
writer.Write7BitEncodedInt(this.customCards.Count);
|
||||||
|
foreach (var card in this.customCards) {
|
||||||
|
writer.Write(card.Line1 ?? card.Name);
|
||||||
|
writer.Write(card.Line2 ?? "");
|
||||||
|
writer.Write((byte) card.Rarity);
|
||||||
|
writer.Write((byte) card.SpecialCost);
|
||||||
|
writer.Write((byte) card.InkColour1.GetValueOrDefault().R);
|
||||||
|
writer.Write((byte) card.InkColour1.GetValueOrDefault().G);
|
||||||
|
writer.Write((byte) card.InkColour1.GetValueOrDefault().B);
|
||||||
|
writer.Write((byte) card.InkColour2.GetValueOrDefault().R);
|
||||||
|
writer.Write((byte) card.InkColour2.GetValueOrDefault().G);
|
||||||
|
writer.Write((byte) card.InkColour2.GetValueOrDefault().B);
|
||||||
|
for (var x = 0; x < 8; x++) {
|
||||||
|
for (var y = 0; y < 8; y += 4)
|
||||||
|
writer.Write((byte) ((int) card.GetSpace(x, y, 0) >> 2 | (int) card.GetSpace(x, y + 1, 0) | (int) card.GetSpace(x, y + 2, 0) << 2 | (int) card.GetSpace(x, y + 3, 0) << 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deck cache
|
// Deck cache
|
||||||
writer.Write7BitEncodedInt(this.deckCache.Count);
|
writer.Write7BitEncodedInt(this.deckCache.Count);
|
||||||
foreach (var deck in this.deckCache) {
|
foreach (var deck in this.deckCache) {
|
||||||
writer.Write(deck.Name);
|
writer.Write(deck.Name);
|
||||||
writer.Write((byte) deck.Sleeves);
|
writer.Write((byte) deck.Sleeves);
|
||||||
foreach (var card in deck.Cards)
|
foreach (var card in deck.Cards)
|
||||||
writer.Write((byte) card.Number);
|
writer.Write((short) card.Number);
|
||||||
|
|
||||||
int upgradesPacked = 0;
|
int upgradesPacked = 0;
|
||||||
for (var i = 0; i < 15; i++)
|
for (var i = 0; i < 15; i++)
|
||||||
|
|
@ -446,7 +704,7 @@ public class Game {
|
||||||
|
|
||||||
// Games
|
// Games
|
||||||
for (int i = 0; i < this.Players[0].Games.Count; i++) {
|
for (int i = 0; i < this.Players[0].Games.Count; i++) {
|
||||||
var stageNumber = Enumerable.Range(0, StageDatabase.Stages.Count).First(j => this.setStages[i] == StageDatabase.Stages[j].Name);
|
var stageNumber = this.setStages[i];
|
||||||
writer.Write((byte) stageNumber);
|
writer.Write((byte) stageNumber);
|
||||||
|
|
||||||
foreach (var player in this.Players) {
|
foreach (var player in this.Players) {
|
||||||
|
|
@ -460,7 +718,7 @@ public class Game {
|
||||||
for (int j = 0; j < 12; j++) {
|
for (int j = 0; j < 12; j++) {
|
||||||
foreach (var player in this.Players) {
|
foreach (var player in this.Players) {
|
||||||
var move = player.Games[i].turns[j];
|
var move = player.Games[i].turns[j];
|
||||||
writer.Write((byte) move.CardNumber);
|
writer.Write((short) move.CardNumber);
|
||||||
writer.Write((byte) ((move.Rotation & 3) | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0) | (move.IsTimeout ? 0x20 : 0)));
|
writer.Write((byte) ((move.Rotation & 3) | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0) | (move.IsTimeout ? 0x20 : 0)));
|
||||||
writer.Write((sbyte) move.X);
|
writer.Write((sbyte) move.X);
|
||||||
writer.Write((sbyte) move.Y);
|
writer.Write((sbyte) move.Y);
|
||||||
|
|
|
||||||
81
TableturfBattleServer/HttpRequestHelper.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Web;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
|
||||||
|
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
|
||||||
|
|
||||||
|
namespace TableturfBattleServer;
|
||||||
|
internal static class HttpRequestHelper {
|
||||||
|
internal static readonly char[] DELIMITERS = [',', ' '];
|
||||||
|
internal const int CUSTOM_CARD_START = -10000;
|
||||||
|
internal const int RECEIVED_CUSTOM_CARD_START = -20000;
|
||||||
|
|
||||||
|
internal static void SetErrorResponse(this HttpListenerResponse response, Error error) {
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error));
|
||||||
|
SetResponse(response, error.HttpStatusCode, "application/json", bytes);
|
||||||
|
}
|
||||||
|
internal static void SetResponse(this HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) {
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(content);
|
||||||
|
SetResponse(response, statusCode, contentType, bytes);
|
||||||
|
}
|
||||||
|
internal static void SetResponse(this HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) {
|
||||||
|
response.StatusCode = (int) statusCode;
|
||||||
|
response.ContentType = contentType;
|
||||||
|
response.ContentLength64 = content.Length;
|
||||||
|
response.Close(content, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Dictionary<string, string> DecodeFormData(Stream stream) => DecodeFormData(new StreamReader(stream).ReadToEnd());
|
||||||
|
internal static Dictionary<string, string> DecodeFormData(TextReader reader) => DecodeFormData(reader.ReadToEnd());
|
||||||
|
internal static Dictionary<string, string> DecodeFormData(string s) {
|
||||||
|
if (s.StartsWith('?')) s = s[1..];
|
||||||
|
return s != ""
|
||||||
|
? s.Split(['&']).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data"))
|
||||||
|
.ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1]))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void SetStaticResponse(HttpListenerRequest request, HttpListenerResponse response, string jsonContent, string eTag, DateTime lastModified) {
|
||||||
|
if (request.HttpMethod is not ("GET" or "HEAD")) {
|
||||||
|
response.AddHeader("Allow", "GET, HEAD");
|
||||||
|
SetErrorResponse(response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.AppendHeader("Cache-Control", "max-age=86400");
|
||||||
|
response.AppendHeader("ETag", eTag);
|
||||||
|
response.AppendHeader("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\""));
|
||||||
|
|
||||||
|
var ifNoneMatch = request.Headers["If-None-Match"];
|
||||||
|
if (ifNoneMatch != null) {
|
||||||
|
if (request.Headers["If-None-Match"] == eTag)
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NotModified;
|
||||||
|
else
|
||||||
|
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
|
||||||
|
} else {
|
||||||
|
if (DateTime.TryParseExact(request.Headers["If-Modified-Since"], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime)
|
||||||
|
&& dateTime >= lastModified.ToUniversalTime())
|
||||||
|
response.StatusCode = (int) HttpStatusCode.NotModified;
|
||||||
|
else
|
||||||
|
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryParseStageSelectionRule(string json, int maxPlayers, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) {
|
||||||
|
try {
|
||||||
|
stageSelectionRule = JsonUtils.Deserialise<StageSelectionRules>(json);
|
||||||
|
if (stageSelectionRule == null) return false;
|
||||||
|
stageSelectionRule.AddUnavailableStages(maxPlayers);
|
||||||
|
// Check that at least one stage is allowed.
|
||||||
|
for (var i = 0; i < StageDatabase.Stages.Count; i++) {
|
||||||
|
if (!stageSelectionRule.BannedStages.Contains(i)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (JsonSerializationException) {
|
||||||
|
stageSelectionRule = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,4 +6,5 @@ internal class JsonUtils {
|
||||||
private static readonly JsonSerializerSettings serializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
|
private static readonly JsonSerializerSettings serializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
|
||||||
|
|
||||||
internal static string Serialise(object? o) => JsonConvert.SerializeObject(o, serializerSettings);
|
internal static string Serialise(object? o) => JsonConvert.SerializeObject(o, serializerSettings);
|
||||||
|
internal static T? Deserialise<T>(string json) => JsonConvert.DeserializeObject<T>(json, serializerSettings);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public class Move {
|
public class Move(Card card, bool isPass, int x, int y, int rotation, bool isSpecialAttack, bool isTimeout) {
|
||||||
public Card Card { get; }
|
public Card Card { get; } = card ?? throw new ArgumentNullException(nameof(card));
|
||||||
public bool IsPass { get; }
|
public bool IsPass { get; } = isPass;
|
||||||
public int X { get; }
|
public int X { get; } = x;
|
||||||
public int Y { get; }
|
public int Y { get; } = y;
|
||||||
public int Rotation { get; }
|
public int Rotation { get; } = rotation;
|
||||||
public bool IsSpecialAttack { get; }
|
public bool IsSpecialAttack { get; } = isSpecialAttack;
|
||||||
public bool IsTimeout { get; }
|
public bool IsTimeout { get; } = isTimeout;
|
||||||
|
|
||||||
public Move(Card card, bool isPass, int x, int y, int rotation, bool isSpecialAttack, bool isTimeout) {
|
|
||||||
this.Card = card ?? throw new ArgumentNullException(nameof(card));
|
|
||||||
this.IsPass = isPass;
|
|
||||||
this.X = x;
|
|
||||||
this.Y = y;
|
|
||||||
this.Rotation = rotation;
|
|
||||||
this.IsSpecialAttack = isSpecialAttack;
|
|
||||||
this.IsTimeout = isTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
public bool ShouldSerializeX() => !this.IsPass;
|
public bool ShouldSerializeX() => !this.IsPass;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public class Placement {
|
public class Placement {
|
||||||
public List<int> Players { get; } = new();
|
public List<int> Players { get; } = [];
|
||||||
[JsonConverter(typeof(SpacesAffectedDictionaryConverter))]
|
[JsonConverter(typeof(SpacesAffectedDictionaryConverter))]
|
||||||
public Dictionary<Point, Space> SpacesAffected { get; } = new();
|
public Dictionary<Point, Space> SpacesAffected { get; } = [];
|
||||||
|
|
||||||
internal class SpacesAffectedDictionaryConverter : JsonConverter<Dictionary<Point, Space>> {
|
internal class SpacesAffectedDictionaryConverter : JsonConverter<Dictionary<Point, Space>> {
|
||||||
public override Dictionary<Point, Space>? ReadJson(JsonReader reader, Type objectType, Dictionary<Point, Space>? existingValue, bool hasExistingValue, JsonSerializer serializer) {
|
public override Dictionary<Point, Space>? ReadJson(JsonReader reader, Type objectType, Dictionary<Point, Space>? existingValue, bool hasExistingValue, JsonSerializer serializer) {
|
||||||
|
|
@ -12,8 +12,7 @@ public class Placement {
|
||||||
return list?.ToDictionary(o => o.space, o => o.newState);
|
return list?.ToDictionary(o => o.space, o => o.newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void WriteJson(JsonWriter writer, Dictionary<Point, Space>? value, JsonSerializer serializer) {
|
public override void WriteJson(JsonWriter writer, Dictionary<Point, Space>? value, JsonSerializer serializer)
|
||||||
serializer.Serialize(writer, value?.Select(e => new { space = e.Key, newState = e.Value }));
|
=> serializer.Serialize(writer, value?.Select(e => new { space = e.Key, newState = e.Value }));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public class Player {
|
public class Player(Game game, string name, Guid token) {
|
||||||
public string Name { get; }
|
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Guid Token { get; }
|
public Guid Token { get; } = token;
|
||||||
public Colour Colour { get; set; }
|
public Colour Colour { get; set; }
|
||||||
public Colour SpecialColour { get; set; }
|
public Colour SpecialColour { get; set; }
|
||||||
public Colour SpecialAccentColour { get; set; }
|
public Colour SpecialAccentColour { get; set; }
|
||||||
public bool UIBaseColourIsSpecialColour { get; set; }
|
public bool UIBaseColourIsSpecialColour { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
private readonly Game game;
|
internal List<TableturfWebSocketBehaviour> Connections { get; } = [];
|
||||||
|
[JsonIgnore]
|
||||||
|
public DateTime? DisconnectedAt { get; set; }
|
||||||
|
public bool IsOnline => this.DisconnectedAt == null;
|
||||||
|
|
||||||
|
public StageSelectionPrompt? StageSelectionPrompt { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
private readonly Game game = game ?? throw new ArgumentNullException(nameof(game));
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal readonly List<int> CardsUsed = new(12);
|
internal readonly List<int> CardsUsed = new(12);
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
|
@ -20,14 +28,18 @@ public class Player {
|
||||||
internal Move? Move;
|
internal Move? Move;
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal Move? ProvisionalMove;
|
internal Move? ProvisionalMove;
|
||||||
|
[JsonIgnore]
|
||||||
|
internal readonly Dictionary<int, int> customCardMap = new();
|
||||||
|
|
||||||
public int GamesWon { get; set; }
|
public int GamesWon { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal List<SingleGameData> Games { get; } = new() { new() };
|
internal List<SingleGameData> Games { get; } = [new()];
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public SingleGameData CurrentGameData => this.Games[^1];
|
public SingleGameData CurrentGameData => this.Games[^1];
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool WonLastGame => this.Games.Count > 1 && this.Games[^2].won;
|
||||||
|
|
||||||
public int SpecialPoints => this.CurrentGameData.SpecialPoints;
|
public int SpecialPoints => this.CurrentGameData.SpecialPoints;
|
||||||
|
|
||||||
|
|
@ -36,19 +48,15 @@ public class Player {
|
||||||
public int? Sleeves => this.CurrentGameData.Deck?.Sleeves;
|
public int? Sleeves => this.CurrentGameData.Deck?.Sleeves;
|
||||||
|
|
||||||
public bool IsReady => this.game.State switch {
|
public bool IsReady => this.game.State switch {
|
||||||
GameState.WaitingForPlayers or GameState.ChoosingStage => this.selectedStageIndex != null,
|
GameState.WaitingForPlayers or GameState.ChoosingStage => this.selectedStages != null,
|
||||||
GameState.ChoosingDeck => this.CurrentGameData.Deck != null,
|
GameState.ChoosingDeck => this.CurrentGameData.Deck != null,
|
||||||
_ => this.Move != null
|
_ => this.Move != null
|
||||||
};
|
};
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal int? selectedStageIndex;
|
internal ICollection<int>? selectedStages;
|
||||||
|
|
||||||
public Player(Game game, string name, Guid token) {
|
internal static readonly int[] RandomStageSelection = [-1];
|
||||||
this.game = game ?? throw new ArgumentNullException(nameof(game));
|
|
||||||
this.Name = name ?? throw new ArgumentNullException(nameof(name));
|
|
||||||
this.Token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearMoves() {
|
public void ClearMoves() {
|
||||||
this.Move = null;
|
this.Move = null;
|
||||||
|
|
@ -79,4 +87,16 @@ public class Player {
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void AddConnection(TableturfWebSocketBehaviour connection) {
|
||||||
|
lock (this.Connections) {
|
||||||
|
this.Connections.Add(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void RemoveConnection(TableturfWebSocketBehaviour connection) {
|
||||||
|
lock (this.Connections) {
|
||||||
|
this.Connections.Remove(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public struct Point {
|
public struct Point(int x, int y) {
|
||||||
public int X;
|
public int X = x;
|
||||||
public int Y;
|
public int Y = y;
|
||||||
|
|
||||||
public Point(int x, int y) {
|
|
||||||
this.X = x;
|
|
||||||
this.Y = y;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,667 +1,150 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Net;
|
||||||
using System.Globalization;
|
using System.Reflection;
|
||||||
using System.Net;
|
using System.Web;
|
||||||
using System.Reflection;
|
using WebSocketSharp.Server;
|
||||||
using System.Text;
|
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
|
||||||
using System.Text.RegularExpressions;
|
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
|
||||||
using System.Timers;
|
|
||||||
using System.Web;
|
namespace TableturfBattleServer;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using TableturfBattleServer.DTO;
|
internal delegate void ApiEndpointGlobalHandler(HttpListenerRequest request, HttpListenerResponse response);
|
||||||
using WebSocketSharp.Server;
|
internal delegate void ApiEndpointGameHandler(Game game, HttpListenerRequest request, HttpListenerResponse response);
|
||||||
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
|
|
||||||
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
|
internal partial class Program {
|
||||||
using Timer = System.Timers.Timer;
|
internal static HttpServer? httpServer;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
private static readonly Dictionary<string, (ApiEndpointAttribute attribute, ApiEndpointGlobalHandler handler)> apiGlobalHandlers = [];
|
||||||
|
private static readonly Dictionary<string, (ApiEndpointAttribute attribute, ApiEndpointGameHandler handler)> apiGameHandlers = [];
|
||||||
internal class Program {
|
private static readonly HashSet<string> spaPaths = [ "/", "/deckeditor", "/cardlist", "/game" , "/replay" ];
|
||||||
internal static HttpServer? httpServer;
|
|
||||||
|
private static string? GetClientRootPath() {
|
||||||
internal static Dictionary<Guid, Game> games = new();
|
var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||||
internal static Dictionary<Guid, Game> inactiveGames = new();
|
while (true) {
|
||||||
internal static readonly Timer timer = new(1000);
|
if (directory == null) return null;
|
||||||
private static bool lockdown;
|
var directory2 = Path.Combine(directory, "TableturfBattleClient");
|
||||||
|
if (Directory.Exists(directory2)) return directory2;
|
||||||
private const int InactiveGameLimit = 1000;
|
directory = Path.GetDirectoryName(directory);
|
||||||
private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5);
|
}
|
||||||
|
}
|
||||||
private static string? GetClientRootPath() {
|
|
||||||
var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
internal static void Main(string[] args) {
|
||||||
while (true) {
|
foreach (var method in typeof(ApiEndpoints).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) {
|
||||||
if (directory == null) return null;
|
var attribute = method.GetCustomAttribute<ApiEndpointAttribute>();
|
||||||
var directory2 = Path.Combine(directory, "TableturfBattleClient");
|
if (attribute == null) continue;
|
||||||
if (Directory.Exists(directory2)) return directory2;
|
if (attribute.Namespace == ApiEndpointNamespace.ApiRoot)
|
||||||
directory = Path.GetDirectoryName(directory);
|
apiGlobalHandlers[attribute.Path] = (attribute, method.CreateDelegate<ApiEndpointGlobalHandler>());
|
||||||
}
|
else
|
||||||
}
|
apiGameHandlers[attribute.Path] = (attribute, method.CreateDelegate<ApiEndpointGameHandler>());
|
||||||
|
}
|
||||||
internal static void Main(string[] args) {
|
|
||||||
httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() };
|
httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() };
|
||||||
|
|
||||||
timer.Elapsed += Timer_Elapsed;
|
httpServer.AddWebSocketService<TableturfWebSocketBehaviour>("/api/websocket");
|
||||||
|
httpServer.OnGet += HttpServer_OnRequest;
|
||||||
httpServer.AddWebSocketService<TableturfWebSocketBehaviour>("/api/websocket");
|
httpServer.OnPost += HttpServer_OnRequest;
|
||||||
httpServer.OnGet += HttpServer_OnRequest;
|
httpServer.Start();
|
||||||
httpServer.OnPost += HttpServer_OnRequest;
|
Console.WriteLine($"Listening on http://{httpServer.Address}:{httpServer.Port}");
|
||||||
httpServer.Start();
|
if (httpServer.DocumentRootPath != null)
|
||||||
Console.WriteLine($"Listening on http://{httpServer.Address}:{httpServer.Port}");
|
Console.WriteLine($"Serving client files from {httpServer.DocumentRootPath}");
|
||||||
if (httpServer.DocumentRootPath != null)
|
else
|
||||||
Console.WriteLine($"Serving client files from {httpServer.DocumentRootPath}");
|
Console.WriteLine($"Client files were not found.");
|
||||||
else
|
|
||||||
Console.WriteLine($"Client files were not found.");
|
while (true) {
|
||||||
|
var s = Console.ReadLine();
|
||||||
while (true) {
|
if (s == null)
|
||||||
var s = Console.ReadLine();
|
Thread.Sleep(Timeout.Infinite);
|
||||||
if (s == null)
|
else {
|
||||||
Thread.Sleep(Timeout.Infinite);
|
s = s.Trim().ToLower();
|
||||||
else {
|
if (s == "update") {
|
||||||
s = s.Trim().ToLower();
|
if (Server.Instance.games.Count == 0)
|
||||||
if (s == "update") {
|
Environment.Exit(2);
|
||||||
if (games.Count == 0)
|
Server.Instance.Lockdown = true;
|
||||||
Environment.Exit(2);
|
Console.WriteLine("Locking server for update.");
|
||||||
lockdown = true;
|
}
|
||||||
Console.WriteLine("Locking server for update.");
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
private static void HttpServer_OnRequest(object? sender, HttpRequestEventArgs e) {
|
||||||
|
e.Response.AppendHeader("Access-Control-Allow-Origin", "*");
|
||||||
private static void Timer_Elapsed(object? sender, ElapsedEventArgs e) {
|
if (!e.Request.RawUrl.StartsWith('/')) {
|
||||||
lock (games) {
|
e.Response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestUrl", "Invalid request URL."));
|
||||||
foreach (var (id, game) in games) {
|
return;
|
||||||
lock (game.Players) {
|
}
|
||||||
game.Tick();
|
|
||||||
if (DateTime.UtcNow - game.abandonedSince >= InactiveGameTimeout) {
|
if (!e.Request.RawUrl.StartsWith("/api/")) {
|
||||||
games.Remove(id);
|
// Static files
|
||||||
inactiveGames.Add(id, game);
|
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
|
||||||
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
|
e.Response.AddHeader("Allow", "GET, HEAD");
|
||||||
if (lockdown && games.Count == 0)
|
e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
||||||
Environment.Exit(2);
|
return;
|
||||||
}
|
}
|
||||||
}
|
var pos = e.Request.RawUrl.IndexOf('/', 1);
|
||||||
}
|
var topLevelFileName = pos < 0 ? e.Request.RawUrl : e.Request.RawUrl[..pos];
|
||||||
if (inactiveGames.Count >= InactiveGameLimit) {
|
var path = spaPaths.Contains(topLevelFileName) ? "index.html" : HttpUtility.UrlDecode(e.Request.RawUrl[1..]);
|
||||||
foreach (var (k, _) in inactiveGames.Select(e => (e.Key, e.Value.abandonedSince)).OrderBy(e => e.abandonedSince).Take(InactiveGameLimit / 2))
|
if (e.TryReadFile(path, out var bytes))
|
||||||
inactiveGames.Remove(k);
|
e.Response.SetResponse(HttpStatusCode.OK, Path.GetExtension(path) switch {
|
||||||
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
|
".html" or ".htm" => "text/html",
|
||||||
}
|
".css" => "text/css",
|
||||||
}
|
".js" => "text/javascript",
|
||||||
}
|
".png" => "image/png",
|
||||||
|
".tar" => "application/x-tar",
|
||||||
private static void HttpServer_OnRequest(object? sender, HttpRequestEventArgs e) {
|
".webp" => "image/webp",
|
||||||
e.Response.AppendHeader("Access-Control-Allow-Origin", "*");
|
".woff" or ".woff2" => "font/woff",
|
||||||
if (!e.Request.RawUrl.StartsWith("/api/")) {
|
_ => "application/octet-stream"
|
||||||
var path = e.Request.RawUrl == "/" || e.Request.RawUrl.StartsWith("/deckeditor") || e.Request.RawUrl.StartsWith("/game/") || e.Request.RawUrl.StartsWith("/replay/")
|
}, bytes);
|
||||||
? "index.html"
|
else
|
||||||
: HttpUtility.UrlDecode(e.Request.RawUrl[1..]);
|
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "File not found."));
|
||||||
if (e.TryReadFile(path, out var bytes))
|
} else {
|
||||||
SetResponse(e.Response, HttpStatusCode.OK,
|
var pos = e.Request.RawUrl.IndexOf('?', 5);
|
||||||
Path.GetExtension(path) switch {
|
var path = pos < 0 ? e.Request.RawUrl[4..] : e.Request.RawUrl[4..pos];
|
||||||
".html" or ".htm" => "text/html",
|
if (apiGlobalHandlers.TryGetValue(path, out var entry)) {
|
||||||
".css" => "text/css",
|
if ((e.Request.HttpMethod == "HEAD" ? "GET" : e.Request.HttpMethod) != entry.attribute.AllowedMethod) {
|
||||||
".js" => "text/javascript",
|
e.Response.AddHeader("Allow", entry.attribute.AllowedMethod == "GET" ? "GET, HEAD" : entry.attribute.AllowedMethod);
|
||||||
".png" => "image/png",
|
e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
||||||
".webp" => "image/webp",
|
return;
|
||||||
".woff" or ".woff2" => "font/woff",
|
}
|
||||||
_ => "application/octet-stream"
|
if (e.Request.ContentLength64 >= 65536) {
|
||||||
}, bytes);
|
e.Response.SetErrorResponse(new(HttpStatusCode.RequestEntityTooLarge, "ContentTooLarge", "Request content is too large."));
|
||||||
else
|
return;
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "File not found."));
|
}
|
||||||
return;
|
entry.handler(e.Request, e.Response);
|
||||||
} else if (e.Request.RawUrl == "/api/games/new") {
|
} else {
|
||||||
if (e.Request.HttpMethod != "POST") {
|
if (!path.StartsWith("/games/")) {
|
||||||
e.Response.AddHeader("Allow", "POST");
|
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
return;
|
||||||
} else if (lockdown) {
|
}
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon."));
|
pos = path.IndexOf('/', 7);
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
var gameIdString = path[7..(pos < 0 ? ^0 : pos)];
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
path = pos < 0 ? "/" : path[pos..];
|
||||||
} else {
|
if (!Guid.TryParse(gameIdString, out var gameID)) {
|
||||||
try {
|
e.Response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID."));
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
return;
|
||||||
Guid clientToken;
|
}
|
||||||
if (!d.TryGetValue("name", out var name)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
|
lock (Server.Instance.games) {
|
||||||
return;
|
if (!Server.Instance.TryGetGame(gameID, out var game)) {
|
||||||
}
|
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "GameNotFound", "Game not found."));
|
||||||
if (name.Length > 32) {
|
return;
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
|
}
|
||||||
return;
|
lock (game.Players) {
|
||||||
}
|
if (!apiGameHandlers.TryGetValue(path, out var entry2)) {
|
||||||
var maxPlayers = 2;
|
e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
|
||||||
if (d.TryGetValue("maxPlayers", out var maxPlayersString)) {
|
return;
|
||||||
if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) {
|
}
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidMaxPlayers", "Invalid player limit."));
|
if ((e.Request.HttpMethod == "HEAD" ? "GET" : e.Request.HttpMethod) != entry2.attribute.AllowedMethod) {
|
||||||
return;
|
e.Response.AddHeader("Allow", entry2.attribute.AllowedMethod == "GET" ? "GET, HEAD" : entry2.attribute.AllowedMethod);
|
||||||
}
|
e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
||||||
}
|
return;
|
||||||
int? turnTimeLimit = null;
|
}
|
||||||
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString) && turnTimeLimitString != "") {
|
if (e.Request.ContentLength64 >= 65536) {
|
||||||
if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
|
e.Response.SetErrorResponse(new(HttpStatusCode.RequestEntityTooLarge, "ContentTooLarge", "Request content is too large."));
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit."));
|
return;
|
||||||
return;
|
}
|
||||||
}
|
entry2.handler(game, e.Request, e.Response);
|
||||||
turnTimeLimit = turnTimeLimit2;
|
}
|
||||||
}
|
}
|
||||||
int? goalWinCount = null;
|
}
|
||||||
if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") {
|
}
|
||||||
if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) {
|
}
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count."));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
goalWinCount = goalWinCount2;
|
|
||||||
}
|
|
||||||
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
|
|
||||||
if (!Guid.TryParse(tokenString, out clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
clientToken = Guid.NewGuid();
|
|
||||||
var game = new Game(maxPlayers) { GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit };
|
|
||||||
game.Players.Add(new(game, name, clientToken));
|
|
||||||
games.Add(game.ID, game);
|
|
||||||
timer.Start();
|
|
||||||
|
|
||||||
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers }));
|
|
||||||
Console.WriteLine($"New game started: {game.ID}; {games.Count} games active; {inactiveGames.Count} inactive");
|
|
||||||
} catch (ArgumentException) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (e.Request.RawUrl == "/api/cards") {
|
|
||||||
SetStaticResponse(e.Request, e.Response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified);
|
|
||||||
} else if (e.Request.RawUrl == "/api/stages") {
|
|
||||||
SetStaticResponse(e.Request, e.Response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified);
|
|
||||||
} else {
|
|
||||||
var m = Regex.Match(e.Request.RawUrl, @"^/api/games/([\w-]+)(?:/(\w+)(?:\?clientToken=([\w-]+))?)?$", RegexOptions.Compiled);
|
|
||||||
if (m.Success) {
|
|
||||||
if (!Guid.TryParse(m.Groups[1].Value, out var gameID)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lock (games) {
|
|
||||||
if (!TryGetGame(gameID, out var game)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "GameNotFound", "Game not found."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lock (game.Players) {
|
|
||||||
switch (m.Groups[2].Value) {
|
|
||||||
case "": {
|
|
||||||
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
|
|
||||||
e.Response.AddHeader("Allow", "GET, HEAD");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "playerData": {
|
|
||||||
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
|
|
||||||
e.Response.AddHeader("Allow", "GET, HEAD");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Guid.TryParse(m.Groups[3].Value, out var clientToken))
|
|
||||||
clientToken = Guid.Empty;
|
|
||||||
|
|
||||||
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new {
|
|
||||||
game,
|
|
||||||
playerData = game.GetPlayer(clientToken, out var playerIndex, out var player)
|
|
||||||
? new PlayerData(playerIndex, player)
|
|
||||||
: null
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "join": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
Guid clientToken;
|
|
||||||
if (!d.TryGetValue("name", out var name)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (name.Length > 32) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
|
|
||||||
if (!Guid.TryParse(tokenString, out clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
clientToken = Guid.NewGuid();
|
|
||||||
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
|
||||||
if (game.State != GameState.WaitingForPlayers) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
player = new Player(game, name, clientToken);
|
|
||||||
if (!game.TryAddPlayer(player, out playerIndex, out var error)) {
|
|
||||||
SetErrorResponse(e.Response, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
game.SendEvent("join", new { playerIndex, player }, false);
|
|
||||||
}
|
|
||||||
// If they're already in the game, resend the original join response instead of an error.
|
|
||||||
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken }));
|
|
||||||
timer.Start();
|
|
||||||
} catch (ArgumentException) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "setTurnTimeLimit": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (game.State != GameState.WaitingForPlayers) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player) || playerIndex != 0) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) {
|
|
||||||
if (turnTimeLimitString == "")
|
|
||||||
game.TurnTimeLimit = null;
|
|
||||||
else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit."));
|
|
||||||
return;
|
|
||||||
} else
|
|
||||||
game.TurnTimeLimit = turnTimeLimit2;
|
|
||||||
} else {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
game.SendEvent("settingsChange", game, false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "chooseStage": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (player.selectedStageIndex != null) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "StageAlreadyChosen", "You've already chosen a stage."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!d.TryGetValue("stage", out var stageName)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stage name."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stageName == "random") {
|
|
||||||
player.selectedStageIndex = -1;
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
|
|
||||||
game.SendPlayerReadyEvent(playerIndex, false);
|
|
||||||
timer.Start();
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
for (var i = 0; i < StageDatabase.Stages.Count; i++) {
|
|
||||||
var stage = StageDatabase.Stages[i];
|
|
||||||
if (stageName == stage.Name) {
|
|
||||||
player.selectedStageIndex = i;
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
|
|
||||||
game.SendPlayerReadyEvent(playerIndex, false);
|
|
||||||
timer.Start();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "StageNotFound", "No such stage is known."));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "chooseDeck": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (player.CurrentGameData.Deck != null) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!d.TryGetValue("deckName", out var deckName)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var deckSleeves = 0;
|
|
||||||
if (d.TryGetValue("deckSleeves", out var deckSleevesString) && (!int.TryParse(deckSleevesString, out deckSleeves) || deckSleeves is < 0 or >= 25)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!d.TryGetValue("deckCards", out var deckString)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var array = deckString.Split(new[] { ',', '+', ' ' }, 15);
|
|
||||||
if (array.Length != 15) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int[]? upgrades = null;
|
|
||||||
if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) {
|
|
||||||
upgrades = new int[15];
|
|
||||||
var array2 = deckUpgradesString.Split(new[] { ',', '+', ' ' }, 15);
|
|
||||||
for (var i = 0; i < 15; i++) {
|
|
||||||
if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2)
|
|
||||||
upgrades[i] = j;
|
|
||||||
else {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cards = new int[15];
|
|
||||||
for (var i = 0; i < 15; i++) {
|
|
||||||
if (!int.TryParse(array[i], out var cardNumber) || !CardDatabase.IsValidCardNumber(cardNumber)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cards[i] = cardNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (d.TryGetValue("stageIndex", out var stageIndexString) && stageIndexString is not ("" or "null" or "undefined")) {
|
|
||||||
if (int.TryParse(stageIndexString, out var stageIndex) && stageIndex >= 0 && stageIndex < StageDatabase.Stages.Count)
|
|
||||||
player.selectedStageIndex = stageIndex;
|
|
||||||
else {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidStage", "Invalid stage index."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15));
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
|
|
||||||
game.SendPlayerReadyEvent(playerIndex, false);
|
|
||||||
timer.Start();
|
|
||||||
} catch (ArgumentException) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "play": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (game.State != GameState.Ongoing) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.Move != null) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var handIndex = player.GetHandIndex(cardNumber);
|
|
||||||
if (handIndex < 0) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "MissingCard", "You don't have that card."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTimeout = d.TryGetValue("isTimeout", out var isTimeoutStr) && isTimeoutStr.ToLower() is not ("false" or "0");
|
|
||||||
|
|
||||||
var card = player.Hand![handIndex];
|
|
||||||
if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) {
|
|
||||||
player.Move = new(card, true, 0, 0, 0, false, isTimeout);
|
|
||||||
} else {
|
|
||||||
var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0");
|
|
||||||
if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x)
|
|
||||||
|| !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y)
|
|
||||||
|| !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
r &= 3;
|
|
||||||
if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout);
|
|
||||||
}
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
|
|
||||||
game.SendPlayerReadyEvent(playerIndex, isTimeout);
|
|
||||||
timer.Start();
|
|
||||||
} catch (ArgumentException) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "redraw": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
if (game.State != GameState.Redraw) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.Move != null) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0");
|
|
||||||
player.Move = new(player.Hand![0], false, 0, 0, 0, redraw, false);
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
|
|
||||||
game.SendPlayerReadyEvent(playerIndex, false);
|
|
||||||
timer.Start();
|
|
||||||
} catch (ArgumentException) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "nextGame": {
|
|
||||||
if (e.Request.HttpMethod != "POST") {
|
|
||||||
e.Response.AddHeader("Allow", "POST");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else if (e.Request.ContentLength64 >= 65536) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
if (game.State is not (GameState.GameEnded or GameState.SetEnded)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var d = DecodeFormData(e.Request.InputStream);
|
|
||||||
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.Move == null) {
|
|
||||||
player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready.
|
|
||||||
game.SendPlayerReadyEvent(playerIndex, false);
|
|
||||||
}
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
|
|
||||||
timer.Start();
|
|
||||||
} catch (ArgumentException) {
|
|
||||||
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "replay": {
|
|
||||||
if (e.Request.HttpMethod != "GET") {
|
|
||||||
e.Response.AddHeader("Allow", "GET");
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
} else {
|
|
||||||
if (game.State != GameState.SetEnded) {
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var ms = new MemoryStream();
|
|
||||||
game.WriteReplayData(ms);
|
|
||||||
SetResponse(e.Response, HttpStatusCode.OK, "application/octet-stream", ms.ToArray());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetErrorResponse(HttpListenerResponse response, Error error) {
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error));
|
|
||||||
SetResponse(response, error.HttpStatusCode, "application/json", bytes);
|
|
||||||
}
|
|
||||||
private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) {
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(content);
|
|
||||||
SetResponse(response, statusCode, contentType, bytes);
|
|
||||||
}
|
|
||||||
private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) {
|
|
||||||
response.StatusCode = (int) statusCode;
|
|
||||||
response.ContentType = contentType;
|
|
||||||
response.ContentLength64 = content.Length;
|
|
||||||
response.Close(content, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, string> DecodeFormData(Stream stream) {
|
|
||||||
using var reader = new StreamReader(stream);
|
|
||||||
var s = reader.ReadToEnd();
|
|
||||||
return s != ""
|
|
||||||
? s.Split(new[] { '&' }).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data"))
|
|
||||||
.ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1]))
|
|
||||||
: new();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetStaticResponse(HttpListenerRequest request, HttpListenerResponse response, string jsonContent, string eTag, DateTime lastModified) {
|
|
||||||
if (request.HttpMethod is not ("GET" or "HEAD")) {
|
|
||||||
response.AddHeader("Allow", "GET, HEAD");
|
|
||||||
SetErrorResponse(response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
response.AppendHeader("Cache-Control", "max-age=86400");
|
|
||||||
response.AppendHeader("ETag", eTag);
|
|
||||||
response.AppendHeader("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\""));
|
|
||||||
|
|
||||||
var ifNoneMatch = request.Headers["If-None-Match"];
|
|
||||||
if (ifNoneMatch != null) {
|
|
||||||
if (request.Headers["If-None-Match"] == eTag)
|
|
||||||
response.StatusCode = (int) HttpStatusCode.NotModified;
|
|
||||||
else
|
|
||||||
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
|
|
||||||
} else {
|
|
||||||
if (DateTime.TryParseExact(request.Headers["If-Modified-Since"], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime)
|
|
||||||
&& dateTime >= lastModified.ToUniversalTime())
|
|
||||||
response.StatusCode = (int) HttpStatusCode.NotModified;
|
|
||||||
else
|
|
||||||
SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool TryGetGame(Guid gameID, [MaybeNullWhen(false)] out Game game) {
|
|
||||||
if (games.TryGetValue(gameID, out game)) {
|
|
||||||
game.abandonedSince = DateTime.UtcNow;
|
|
||||||
return true;
|
|
||||||
} else if (inactiveGames.TryGetValue(gameID, out game)) {
|
|
||||||
inactiveGames.Remove(gameID);
|
|
||||||
games[gameID] = game;
|
|
||||||
game.abandonedSince = DateTime.UtcNow;
|
|
||||||
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
55
TableturfBattleServer/Server.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Timers;
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace TableturfBattleServer;
|
||||||
|
internal class Server {
|
||||||
|
public static Server Instance { get; } = new();
|
||||||
|
|
||||||
|
internal Dictionary<Guid, Game> games = [];
|
||||||
|
internal Dictionary<Guid, Game> inactiveGames = [];
|
||||||
|
public bool Lockdown { get; set; }
|
||||||
|
internal readonly Timer timer = new(1000);
|
||||||
|
|
||||||
|
private const int InactiveGameLimit = 1000;
|
||||||
|
private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private Server() => this.timer.Elapsed += this.Timer_Elapsed;
|
||||||
|
|
||||||
|
internal bool TryGetGame(Guid gameID, [MaybeNullWhen(false)] out Game game) {
|
||||||
|
if (games.TryGetValue(gameID, out game)) {
|
||||||
|
game.abandonedSince = DateTime.UtcNow;
|
||||||
|
return true;
|
||||||
|
} else if (inactiveGames.TryGetValue(gameID, out game)) {
|
||||||
|
inactiveGames.Remove(gameID);
|
||||||
|
games[gameID] = game;
|
||||||
|
game.abandonedSince = DateTime.UtcNow;
|
||||||
|
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Timer_Elapsed(object? sender, ElapsedEventArgs e) {
|
||||||
|
lock (games) {
|
||||||
|
foreach (var (id, game) in games) {
|
||||||
|
lock (game.Players) {
|
||||||
|
game.Tick();
|
||||||
|
if (DateTime.UtcNow - game.abandonedSince >= InactiveGameTimeout) {
|
||||||
|
games.Remove(id);
|
||||||
|
inactiveGames.Add(id, game);
|
||||||
|
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
|
||||||
|
if (Lockdown && games.Count == 0)
|
||||||
|
Environment.Exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inactiveGames.Count >= InactiveGameLimit) {
|
||||||
|
foreach (var (k, _) in inactiveGames.Select(e => (e.Key, e.Value.abandonedSince)).OrderBy(e => e.abandonedSince).Take(InactiveGameLimit / 2))
|
||||||
|
inactiveGames.Remove(k);
|
||||||
|
Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
public class Stage {
|
public class Stage(string name, Space[,] grid, Point[][] startSpaces) {
|
||||||
public string Name { get; }
|
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
internal readonly Space[,] grid;
|
internal Space[,] Grid = grid ?? throw new ArgumentNullException(nameof(grid));
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The lists of starting spaces on this stage.
|
/// The lists of starting spaces on this stage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -13,11 +13,8 @@ public class Stage {
|
||||||
/// For example, if there is a list of 3 and a list of 4, the list of 3 will be used for 2 or 3 players, and the list of 4 will be used for 4 players.
|
/// For example, if there is a list of 3 and a list of 4, the list of 3 will be used for 2 or 3 players, and the list of 4 will be used for 4 players.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonProperty]
|
[JsonProperty]
|
||||||
internal readonly Point[][] startSpaces;
|
internal Point[][] StartSpaces = startSpaces ?? throw new ArgumentNullException(nameof(startSpaces));
|
||||||
|
|
||||||
public Stage(string name, Space[,] grid, Point[][] startSpaces) {
|
[JsonIgnore]
|
||||||
this.Name = name ?? throw new ArgumentNullException(nameof(name));
|
public int MaxPlayers => this.StartSpaces.Max(a => a.Length);
|
||||||
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));
|
|
||||||
this.startSpaces = startSpaces ?? throw new ArgumentNullException(nameof(startSpaces));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
internal class StageDatabase {
|
internal class StageDatabase {
|
||||||
private const Space E = Space.Empty;
|
private const Space E = Space.Empty;
|
||||||
|
private const Space W = Space.Wall;
|
||||||
private const Space o = Space.OutOfBounds;
|
private const Space o = Space.OutOfBounds;
|
||||||
|
private const Space a = Space.Ink1;
|
||||||
|
private const Space b = Space.Ink2;
|
||||||
|
private const Space A = Space.SpecialInactive1;
|
||||||
|
private const Space B = Space.SpecialInactive2;
|
||||||
|
|
||||||
private static readonly Stage[] stages = new Stage[] {
|
private static readonly Stage[] stages = [
|
||||||
new("Main Street", new Space[9, 26], new[] {
|
new("Main Street", new Space[9, 26], [
|
||||||
new Point[] { new(4, 22), new(4, 3), new(4, 13) },
|
[new(4, 22), new(4, 3), new(4, 13)],
|
||||||
new Point[] { new(2, 22), new(6, 3), new(6, 22), new(2, 3) }
|
[new(2, 22), new(6, 3), new(6, 22), new(2, 3)]
|
||||||
}),
|
]),
|
||||||
new("Thunder Point", new Space[,] {
|
new("Thunder Point", new Space[,] {
|
||||||
{ o, o, o, o, o, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
{ o, o, o, o, o, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
{ o, o, o, o, o, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
{ o, o, o, o, o, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
|
@ -29,10 +32,10 @@ internal class StageDatabase {
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o, o, o },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o, o, o },
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o, o, o },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o, o, o },
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o, o, o },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o, o, o },
|
||||||
}, new[] {
|
}, [
|
||||||
new Point[] { new(3, 18), new(12, 3), new(7, 11) },
|
[new(3, 18), new(12, 3), new(7, 11)],
|
||||||
new Point[] { new(3, 18), new(12, 3), new(12, 11), new(3, 10) },
|
[new(3, 18), new(12, 3), new(12, 11), new(3, 10)],
|
||||||
}),
|
]),
|
||||||
new("X Marks the Garden", new Space[,] {
|
new("X Marks the Garden", new Space[,] {
|
||||||
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
||||||
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
||||||
|
|
@ -53,8 +56,8 @@ internal class StageDatabase {
|
||||||
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
||||||
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
||||||
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, E, E, E, E, E, E, o, o, o, o, o, o, o, o },
|
||||||
}, new[] { new Point[] { new(9, 19), new(x: 9, 3), new(15, 11), new(3, 11) } }),
|
}, [[new(9, 19), new(x: 9, 3), new(15, 11), new(3, 11)]]),
|
||||||
new("Square Squared", new Space[15, 15], new[] { new Point[] { new(3, 11), new(11, 3), new(11, 11), new(3, 3) }}),
|
new("Square Squared", new Space[15, 15], [[new(3, 11), new(11, 3), new(11, 11), new(3, 3)]]),
|
||||||
new("Lakefront Property", new Space[,] {
|
new("Lakefront Property", new Space[,] {
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
||||||
|
|
@ -72,7 +75,7 @@ internal class StageDatabase {
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
||||||
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, },
|
||||||
}, new[] { new Point[] { new(3, 12), new(12, 3), new(12, 12), new(3, 3) }}),
|
}, [[new(3, 12), new(12, 3), new(12, 12), new(3, 3)]]),
|
||||||
new("Double Gemini", new Space[,] {
|
new("Double Gemini", new Space[,] {
|
||||||
{ o, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, o },
|
||||||
{ o, o, o, o, o, o, o, E, E, E, o, o, o, o, o, E, E, E, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, E, E, E, o, o, o, o, o, E, E, E, o, o, o, o, o, o, o },
|
||||||
|
|
@ -91,10 +94,10 @@ internal class StageDatabase {
|
||||||
{ o, o, o, o, o, o, E, E, E, E, E, o, o, o, E, E, E, E, E, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, E, E, E, E, E, o, o, o, E, E, E, E, E, o, o, o, o, o, o },
|
||||||
{ o, o, o, o, o, o, o, E, E, E, o, o, o, o, o, E, E, E, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, E, E, E, o, o, o, o, o, E, E, E, o, o, o, o, o, o, o },
|
||||||
{ o, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, o },
|
{ o, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, E, o, o, o, o, o, o, o, o },
|
||||||
}, new[] {
|
}, [
|
||||||
new Point[] { new(8, 19), new(8, 5), new(8, 12) },
|
[new(8, 19), new(8, 5), new(8, 12)],
|
||||||
new Point[] { new(5, 16), new(11, 8), new(11, 16), new(5, 8) }
|
[new(5, 16), new(11, 8), new(11, 16), new(5, 8)]
|
||||||
}),
|
]),
|
||||||
new("River Drift", new Space[,] {
|
new("River Drift", new Space[,] {
|
||||||
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, E, E, E, E, E, E, E },
|
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, E, E, E, E, E, E, E },
|
||||||
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, E, E, E, E, E, E, E },
|
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, E, E, E, E, E, E, E },
|
||||||
|
|
@ -113,15 +116,141 @@ internal class StageDatabase {
|
||||||
{ E, E, E, E, E, E, E, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
{ E, E, E, E, E, E, E, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
||||||
{ E, E, E, E, E, E, E, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
{ E, E, E, E, E, E, E, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
||||||
{ E, E, E, E, E, E, E, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
{ E, E, E, E, E, E, E, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
||||||
}, new[] {
|
}, [
|
||||||
new Point[] { new(3, 21), new(13, 3), new(8, 12) },
|
[new(3, 21), new(13, 3), new(8, 12)],
|
||||||
new Point[] { new(3, 21), new(13, 3), new(8, 16), new(8, 8) }
|
[new(3, 21), new(13, 3), new(8, 16), new(8, 8)]
|
||||||
}),
|
]),
|
||||||
new("Box Seats", new Space[10, 10], new[] { new Point[] { new(2, 7), new(7, 2), new(7, 7), new(2, 2) }}),
|
new("Box Seats", new Space[10, 10], [[new(2, 7), new(7, 2), new(7, 7), new(2, 2)]]),
|
||||||
};
|
new("Girder for Battle", new Space[,] {
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, o, o, o, o, o, o, E, E, E, E, E, E },
|
||||||
|
}, [
|
||||||
|
[new(8, 17), new(8, 0), new(8, 9)],
|
||||||
|
[new(2, 17), new(14, 0), new(14, 17), new(2, 0)]
|
||||||
|
]),
|
||||||
|
new("Mask Mansion", new Space[,] {
|
||||||
|
{ o, o, o, E, E, E, E, E, E, E, E, E, E, E, o, o, o },
|
||||||
|
{ o, o, o, E, E, E, E, E, E, E, E, E, E, E, o, o, o },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, W, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, W, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, W, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, W, E, E, E, E, E, E, E, W, E, E, E, E },
|
||||||
|
{ E, E, E, E, W, E, E, E, E, E, E, E, W, E, E, E, E },
|
||||||
|
{ E, E, E, E, W, E, E, E, E, E, E, E, W, E, E, E, E },
|
||||||
|
{ E, E, E, E, W, E, E, E, E, E, E, E, W, E, E, E, E },
|
||||||
|
{ E, E, E, E, W, E, E, E, E, E, E, E, W, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, W, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, W, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, W, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ o, o, o, E, E, E, E, E, E, E, E, E, E, E, o, o, o },
|
||||||
|
{ o, o, o, E, E, E, E, E, E, E, E, E, E, E, o, o, o },
|
||||||
|
}, [
|
||||||
|
[new(8, 15), new(8, 1), new(8, 8)],
|
||||||
|
[new(3, 15), new(13, 1), new(13, 15), new(3, 1)]
|
||||||
|
]),
|
||||||
|
new("Sticky Thicket", new Space[,] {
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, B, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, A, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, B, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, A, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, W, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
}, [
|
||||||
|
[new(3, 20), new(3, 4)]
|
||||||
|
]),
|
||||||
|
new("Cracker Snap", new Space[,] {
|
||||||
|
{ o, o, o, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ o, o, o, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, o, o, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, o, o, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, o, o, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, o, o, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, o, o, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, o, o, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, o, o, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, o, o, o, o, o },
|
||||||
|
}, [
|
||||||
|
[new(2, 17), new(11, 2), new(7, 11)],
|
||||||
|
[new(9, 17), new(4, 2), new(11, 10), new(2, 9)]
|
||||||
|
]),
|
||||||
|
new("Two-Lane Splattop", new Space[,] {
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, B, E, E, E, E, E, E, E, E, E, E, E, E, A, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o, o },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, B, E, E, E, E, E, E, E, E, E, E, E, E, A, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
}, [
|
||||||
|
[new(11, 15), new(11, 2)]
|
||||||
|
]),
|
||||||
|
new("Pedal to the Metal", new Space[,] {
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, B, B, B, E, E, E, E, E, E, E, E, E, E, E, E, E, A, A, A, E, E, E },
|
||||||
|
{ E, E, E, B, B, B, E, E, E, E, E, E, E, E, E, E, E, E, E, A, A, A, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
}, [
|
||||||
|
[new(4, 19), new(4, 3)]
|
||||||
|
]),
|
||||||
|
new("Over the Line", new Space[,] {
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, A, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, A, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, B, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, B, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
{ E, E, E, E, E, E, E, E, E, E, b, a, E, E, E, E, E, E, E, E, E, E },
|
||||||
|
}, [
|
||||||
|
[new(1, 11), new(8, 10)]
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
public static Version Version { get; } = new(1, 2, 0, 1);
|
public static Version Version { get; } = new(2, 0, 1, 0);
|
||||||
public static DateTime LastModified { get; } = new(2023, 4, 12, 23, 0, 0, DateTimeKind.Utc);
|
public static DateTime LastModified { get; } = new(2024, 2, 24, 10, 0, 0, DateTimeKind.Utc);
|
||||||
public static string JSON { get; }
|
public static string JSON { get; }
|
||||||
public static ReadOnlyCollection<Stage> Stages { get; }
|
public static ReadOnlyCollection<Stage> Stages { get; }
|
||||||
|
|
||||||
|
|
|
||||||
20
TableturfBattleServer/StageSelectionPrompt.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace TableturfBattleServer;
|
||||||
|
public struct StageSelectionPrompt {
|
||||||
|
public StageSelectionPromptType PromptType;
|
||||||
|
public int NumberOfStagesToStrike;
|
||||||
|
public ICollection<int> StruckStages;
|
||||||
|
public ICollection<int> BannedStages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StageSelectionPromptType {
|
||||||
|
/// <summary>The player is prompted to vote for a stage.</summary>
|
||||||
|
Vote,
|
||||||
|
/// <summary>The player is prompted to vote for whether to strike first or second.</summary>
|
||||||
|
VoteOrder,
|
||||||
|
/// <summary>The player is prompted to choose stages to strike.</summary>
|
||||||
|
Strike,
|
||||||
|
/// <summary>The player is prompted to choose the stage.</summary>
|
||||||
|
Choose,
|
||||||
|
/// <summary>It is another player's turn to make a choice.</summary>
|
||||||
|
Wait
|
||||||
|
}
|
||||||
35
TableturfBattleServer/StageSelectionRules.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
namespace TableturfBattleServer;
|
||||||
|
|
||||||
|
public class StageSelectionRules(StageSelectionMethod method, int[]? bannedStages) {
|
||||||
|
public StageSelectionMethod Method { get; set; } = method;
|
||||||
|
public int[] BannedStages { get; set; } = bannedStages ?? Array.Empty<int>();
|
||||||
|
|
||||||
|
public static StageSelectionRules Default { get; } = new(StageSelectionMethod.Vote, Array.Empty<int>());
|
||||||
|
|
||||||
|
public void AddUnavailableStages(int maxPlayers) {
|
||||||
|
if (maxPlayers == 2) return;
|
||||||
|
|
||||||
|
var list = new List<int>(this.BannedStages);
|
||||||
|
for (var i = 0; i < StageDatabase.Stages.Count; i++) {
|
||||||
|
if (maxPlayers > StageDatabase.Stages[i].MaxPlayers)
|
||||||
|
list.Add(i);
|
||||||
|
}
|
||||||
|
this.BannedStages = list.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StageSelectionMethod {
|
||||||
|
/// <summary>The battle will be on the same stage as the last battle. This cannot be used for the first battle.</summary>
|
||||||
|
Same,
|
||||||
|
/// <summary>Each player votes for a stage, or random. One of the votes, chosen randomly, decides the stage.</summary>
|
||||||
|
Vote,
|
||||||
|
/// <summary>The stage is chosen randomly. If only one stage is allowed, all battles will be on that stage.</summary>
|
||||||
|
Random,
|
||||||
|
/// <summary>The loser of the last battle chooses the stage. This cannot be used for the first battle or after a draw.</summary>
|
||||||
|
Counterpick,
|
||||||
|
/// <summary>
|
||||||
|
/// Players take turns to ban stages for the next match, until the final player chooses the stage from among the remaining ones.
|
||||||
|
/// For the first battle or after a draw, players vote on who shall strike first. For subsequent battles, the winner of the last battle strikes first.
|
||||||
|
/// </summary>
|
||||||
|
Strike
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<PackageProjectUrl>https://github.com/AndrioCelos/TableturfBattleApp</PackageProjectUrl>
|
<PackageProjectUrl>https://github.com/AndrioCelos/TableturfBattleApp</PackageProjectUrl>
|
||||||
<RepositoryUrl>https://github.com/AndrioCelos/TableturfBattleApp</RepositoryUrl>
|
<RepositoryUrl>https://github.com/AndrioCelos/TableturfBattleApp</RepositoryUrl>
|
||||||
<Version>0.0.0.0</Version>
|
<Version>0.0.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using WebSocketSharp.Server;
|
using WebSocketSharp.Server;
|
||||||
|
|
||||||
namespace TableturfBattleServer;
|
namespace TableturfBattleServer;
|
||||||
|
|
@ -19,20 +18,25 @@ internal class TableturfWebSocketBehaviour : WebSocketBehavior {
|
||||||
this.ClientToken = clientToken;
|
this.ClientToken = clientToken;
|
||||||
|
|
||||||
// Send an initial state payload.
|
// Send an initial state payload.
|
||||||
if (Program.TryGetGame(this.GameID, out var game)) {
|
if (Server.Instance.TryGetGame(this.GameID, out var game)) {
|
||||||
this.Game = game;
|
|
||||||
DTO.PlayerData? playerData = null;
|
DTO.PlayerData? playerData = null;
|
||||||
for (int i = 0; i < game.Players.Count; i++) {
|
for (int i = 0; i < game.Players.Count; i++) {
|
||||||
var player = game.Players[i];
|
var player = game.Players[i];
|
||||||
if (player.Token == this.ClientToken) {
|
if (player.Token == this.ClientToken) {
|
||||||
this.Player = player;
|
this.Player = player;
|
||||||
playerData = new(i, player);
|
playerData = new(i, player);
|
||||||
|
game.AddConnection(i, this);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", game, playerData)));
|
this.Game = game;
|
||||||
|
this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", game, playerData, this.ClientToken == game.HostClientToken)));
|
||||||
} else
|
} else
|
||||||
this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", null, null)));
|
this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", null, null, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClose(WebSocketSharp.CloseEventArgs e) {
|
||||||
|
if (this.Player != null) this.Game?.RemoveConnection(this.Player, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SendInternal(string data) => this.Send(data);
|
internal void SendInternal(string data) => this.Send(data);
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 659 KiB |
BIN
images/screenshot3.png
Normal file
|
After Width: | Height: | Size: 771 KiB |
BIN
images/screenshot4.png
Normal file
|
After Width: | Height: | Size: 465 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
# MIT License
|
||||||
|
|
||||||
Copyright (c) 2022 Andrea Giannone
|
Copyright (c) 2022-2024 Andrio Celos
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,9 @@ _Splatoon_ is © Nintendo. This is a fan project and is not affiliated with Nint
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
|
||||||