Add import from screenshot and import/export multiple decks at once

This commit is contained in:
Andrio Celos 2023-06-28 15:55:51 +10:00
parent 7cc4c35b37
commit e417b148c6
4 changed files with 213 additions and 32 deletions

View File

@ -371,6 +371,7 @@
<section id="deckEditordeckListPage">
<a id="deckListBackButton" href=".">Back</a>
<h3>Deck list</h3>
<button id="deckExportAllButton">Export all</button>
<div id="deckList" class="deckList">
<div id="addDeckControls">
<button id="newDeckButton">New deck</button>
@ -395,12 +396,53 @@
</section>
<dialog id="deckImportDialog">
<form id="deckImportForm" method="dialog">
<textarea id="deckImportTextBox" rows="24" cols="45"></textarea>
<div id="deckImportErrorBox" class="error" hidden></div>
<div>
<label for="deckImportTextButton">
<input type="radio" id="deckImportTextButton" name="deckImportMethod"/>
Import from a Tableturf Battle simulator
</label>
<section id="deckImportTextSection">
<textarea id="deckImportTextBox" rows="4" cols="45" autocomplete="off" placeholder="Paste the export string here."></textarea>
<button type="submit" id="deckImportOkButton">OK</button>
<button type="submit" id="deckImportCancelButton">Cancel</button>
</div>
</section>
<label for="deckImportScreenshotButton">
<input type="radio" id="deckImportScreenshotButton" name="deckImportMethod"/>
Import from screenshots
</label>
<section id="deckImportScreenshotSection">
<input type="file" id="deckImportFileBox" accept="image/png,image/jpeg,image/webp,image/bmp" multiple autocomplete="off"/>
<div>
<button type="button" id="deckImportScreenshotInstructionsButton">Show instructions</button>
<div id="deckImportScreenshotInstructions" hidden>
<ol>
<li>In <em>Splatoon 3</em>, open the main menu with X, select the Status tab, the Tableturf Battle section, then Card List.</li>
<li>Enter the Edit Deck menu with Y.</li>
<li>Move the cursor to the deck you want to import.</li>
<li>Take a screenshot using the capture button on your controller.<br/>If you have an external capture device, you can alternatively take a screenshot that way.</li>
<li>Repeat steps 3 and 4 for each deck you want to import.</li>
</ol>
<label for="deckImportScreenshotInstructionsButtonPC">
<input type="radio" id="deckImportScreenshotInstructionsButtonPC" name="deckImportScreenshotMethod"/>
Download screenshots using a PC
</label>
<label for="deckImportScreenshotInstructionsButtonMobile">
<input type="radio" id="deckImportScreenshotInstructionsButtonMobile" name="deckImportScreenshotMethod"/>
Download screenshots using a mobile device
</label>
<ol id="deckImportScreenshotInstructionsPC" start="6" hidden>
<li>Go to System Settings, then select Data Management, then Manage Screenshots and Videos, then Copy to PC via USB Connection.</li>
<li>Connect a USB cable to your PC and the USB-C port on the bottom of your Nintendo Switch.</li>
<li>The Nintendo Switch Album is now accessible in This PC or Devices in your file manager. Click Browse below and select the screenshots you created.</li>
</ol>
<ol id="deckImportScreenshotInstructionsMobile" start="6" hidden>
<li>In the Album, select one of the screenshots you created.</li>
<li>Enter the Editing and Posting menu with A, then select Send to Smart Device.</li>
<li>Follow the prompts to download the screenshots on your mobile device.<br/>On Android devices, it may be necessary to turn off mobile data.</li>
</ol>
</div>
</div>
</section>
<div id="deckImportErrorBox" class="error" hidden></div>
<button type="submit" id="deckImportCancelButton">Cancel</button>
</form>
</dialog>
<dialog id="deckExportDialog">

View File

@ -19,13 +19,26 @@ const deckExportDialog = document.getElementById('deckExportDialog') as HTMLDial
const deckExportCopyButton = document.getElementById('deckExportCopyButton') as HTMLButtonElement;
const deckExportTextBox = document.getElementById('deckExportTextBox') as HTMLTextAreaElement;
const deckExportAllButton = document.getElementById('deckExportAllButton') as HTMLButtonElement;
const deckImportDialog = document.getElementById('deckImportDialog') as HTMLDialogElement;
const deckImportForm = document.getElementById('deckImportForm') as HTMLFormElement;
const deckImportTextBox = document.getElementById('deckImportTextBox') as HTMLTextAreaElement;
const deckImportTextButton = document.getElementById('deckImportTextButton') as HTMLInputElement;
const deckImportTextSection = document.getElementById('deckImportTextSection')!;
const deckImportScreenshotButton = document.getElementById('deckImportScreenshotButton') as HTMLInputElement;
const deckImportScreenshotSection = document.getElementById('deckImportScreenshotSection')!;
const deckImportScreenshotInstructionsButton = document.getElementById('deckImportScreenshotInstructionsButton') as HTMLButtonElement;
const deckImportScreenshotInstructionsButtonPC = document.getElementById('deckImportScreenshotInstructionsButtonPC') as HTMLInputElement;
const deckImportScreenshotInstructionsButtonMobile = document.getElementById('deckImportScreenshotInstructionsButtonMobile') as HTMLInputElement;
const deckImportScreenshotInstructions = document.getElementById('deckImportScreenshotInstructions')!;
const deckImportScreenshotInstructionsPC = document.getElementById('deckImportScreenshotInstructionsPC')!;
const deckImportScreenshotInstructionsMobile = document.getElementById('deckImportScreenshotInstructionsMobile')!;
const deckImportFileBox = document.getElementById('deckImportFileBox') as HTMLInputElement;
const deckImportErrorBox = document.getElementById('deckImportErrorBox')!;
const deckImportOkButton = document.getElementById('deckImportOkButton') as HTMLButtonElement;
const deckButtons = new CheckButtonGroup<number>();
const deckButtons = new CheckButtonGroup<Deck>();
function showDeckList() {
showPage('deckList');
@ -84,16 +97,39 @@ function saveDecks() {
function createDeckButton(index: number, deck: Deck) {
const buttonElement = document.createElement('button');
buttonElement.type = 'button';
buttonElement.dataset.index = index.toString();
deckButtons.add(new CheckButton(buttonElement), index);
const button = new CheckButton(buttonElement);
deckButtons.add(button, deck);
buttonElement.addEventListener('click', () => {
selectedDeck = decks[deckButtons.value!];
selectedDeck = deckButtons.value;
selectDeck();
});
buttonElement.innerText = deck.name;
deckList.insertBefore(buttonElement, addDeckControls);
return buttonElement;
return button;
}
function importDecks(decksToImport: (Deck | number[])[]) {
let newSelectedDeck: Deck | null = null;
for (const el of decksToImport) {
let deck;
if (el instanceof Array)
deck = new Deck(`Imported Deck ${decks.length + 1}`, el, false);
else {
deck = el;
if (!deck.name) deck.name = `Imported Deck ${decks.length + 1}`;
}
createDeckButton(decks.length, deck);
decks.push(deck);
newSelectedDeck ??= deck;
}
if (newSelectedDeck) {
selectedDeck = newSelectedDeck;
deckButtons.deselect();
deckButtons.entries.find(e => e.value == newSelectedDeck)!.button.checked = true;
selectDeck();
saveDecks();
}
}
newDeckButton.addEventListener('click', () => {
@ -104,17 +140,28 @@ newDeckButton.addEventListener('click', () => {
});
importDeckButton.addEventListener('click', () => {
deckImportErrorBox.hidden = true;
deckImportTextSection.hidden = true;
deckImportScreenshotSection.hidden = true;
deckImportScreenshotInstructions.hidden = true;
deckImportScreenshotInstructionsPC.hidden = true;
deckImportScreenshotInstructionsMobile.hidden = true;
deckImportTextButton.checked = false;
deckImportScreenshotButton.checked = false;
deckImportScreenshotInstructionsButtonPC.checked = false;
deckImportScreenshotInstructionsButtonMobile.checked = false;
deckImportScreenshotInstructionsButton.innerText = 'Show instructions';
deckImportDialog.showModal();
});
deckImportForm.addEventListener('submit', e => {
if (e.submitter == deckImportOkButton) {
try {
const deck = JSON.parse(deckImportTextBox.value) as Deck;
if (typeof(deck) != 'object' || !Array.isArray(deck.cards) || deck.cards.length != 15 || deck.cards.find(i => i < 0 || i > cardDatabase.cards!.length))
throw new SyntaxError('Invalid JSON deck');
if (!deck.name) deck.name = `Deck ${decks.length + 1}`;
createDeckButton(decks.length, deck);
decks.push(deck);
const data = JSON.parse(deckImportTextBox.value);
const decks = (data instanceof Array ? data : [ data ]) as Deck[];
for (const deck of decks) {
if (typeof(deck) != 'object' || !Array.isArray(deck.cards) || deck.cards.length != 15 || deck.cards.find(i => i < 0 || i > cardDatabase.cards!.length))
throw new SyntaxError('Invalid JSON deck');
}
importDecks(decks);
} catch (ex: any) {
e.preventDefault();
deckImportErrorBox.innerText = ex.message;
@ -167,13 +214,12 @@ function deselectDeck() {
deckExportButton.addEventListener('click', () => {
if (selectedDeck == null) return;
const json = JSON.stringify(selectedDeck, [ 'name', 'cards' ], '\t');
const json = JSON.stringify(selectedDeck, [ 'name', 'cards' ]);
deckExportTextBox.value = json;
deckExportCopyButton.innerText = 'Copy';
deckExportDialog.showModal();
});
deckExportCopyButton.addEventListener('click', () => {
if (selectedDeck == null) return;
navigator.clipboard.writeText(deckExportTextBox.value);
deckExportCopyButton.innerText = 'Copied';
});
@ -190,31 +236,22 @@ deckRenameButton.addEventListener('click', () => {
deckCopyButton.addEventListener('click', () => {
if (selectedDeck == null) return;
selectedDeck = new Deck(selectedDeck.name, Array.from(selectedDeck.cards), false);
const button = createDeckButton(decks.length, selectedDeck);
decks.push(selectedDeck);
(button.getElementsByTagName('input')[0] as HTMLInputElement).checked = true;
selectDeck();
saveDecks();
importDecks([ new Deck(`${selectedDeck.name} - Copy`, Array.from(selectedDeck.cards), false) ]);
});
deckDeleteButton.addEventListener('click', () => {
if (selectedDeck == null) return;
const index = decks.indexOf(selectedDeck);
if (index < 0) return;
if (!confirm(`Are you sure you want to delete ${selectedDeck.name}?`)) return;
let removed = false;
for (const el of deckButtons.entries) {
if (removed) {
el.value--;
} else if (el.value == index) {
if (el.value == selectedDeck) {
deckList.removeChild(el.button.buttonElement);
removed = true;
break;
}
}
decks.splice(index, 1);
const index = decks.indexOf(selectedDeck);
if (index >= 0) decks.splice(index, 1);
deckButtons.entries.splice(index, 1);
deckButtons.deselect();
selectedDeck = null;
@ -222,5 +259,53 @@ deckDeleteButton.addEventListener('click', () => {
saveDecks();
});
deckImportTextButton.addEventListener('input', () => {
deckImportTextSection.hidden = false;
deckImportScreenshotSection.hidden = true;
});
deckImportScreenshotButton.addEventListener('input', () => {
deckImportTextSection.hidden = true;
deckImportScreenshotSection.hidden = false;
});
deckImportScreenshotInstructionsButton.addEventListener('click', () => {
if (deckImportScreenshotInstructions.hidden) {
deckImportScreenshotInstructions.hidden = false;
deckImportScreenshotInstructionsButton.innerText = 'Hide instructions';
} else {
deckImportScreenshotInstructions.hidden = true;
deckImportScreenshotInstructionsButton.innerText = 'Show instructions';
}
});
deckImportScreenshotInstructionsButtonPC.addEventListener('input', () => {
deckImportScreenshotInstructionsPC.hidden = false;
deckImportScreenshotInstructionsMobile.hidden = true;
});
deckImportScreenshotInstructionsButtonMobile.addEventListener('input', () => {
deckImportScreenshotInstructionsPC.hidden = true;
deckImportScreenshotInstructionsMobile.hidden = false;
});
deckImportFileBox.addEventListener('change', async () => {
if (deckImportFileBox.files && deckImportFileBox.files.length > 0) {
try {
const bitmaps = await Promise.all(Array.from(deckImportFileBox.files, f => createImageBitmap(f)));
importDecks(bitmaps.map(getCardListFromImageBitmap));
} catch (ex: any) {
deckImportErrorBox.innerText = ex.message;
deckImportErrorBox.hidden = false;
}
deckImportFileBox.value = '';
deckImportDialog.close();
}
});
deckExportAllButton.addEventListener('click', () => {
const json = JSON.stringify(decks.filter(d => !d.isReadOnly), [ 'name', 'cards' ]);
deckExportTextBox.value = json;
deckExportCopyButton.innerText = 'Copy';
deckExportDialog.showModal();
});
if (!canPushState)
deckListBackButton.href = '#';

View File

@ -0,0 +1,45 @@
function getCardListFromImageBitmap(bitmap: ImageBitmap) {
if (!cardDatabase.cards) throw new Error('Card database is not yet initialised.');
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
function getR(x: number, y: number) { return imageData.data[(y * imageData.width + x) * 4]; }
function getG(x: number, y: number) { return imageData.data[(y * imageData.width + x) * 4 + 1]; }
function getB(x: number, y: number) { return imageData.data[(y * imageData.width + x) * 4 + 2]; }
function getA(x: number, y: number) { return imageData.data[(y * imageData.width + x) * 4 + 3]; }
const cards = [ ];
for (let i = 0; i < 15; i++) {
const cx = i % 3, cy = Math.floor(i / 3);
const grid: Space[][] = [ ];
for (let x = 0; x < 8; x++) {
const col = [ ];
for (let y = 0; y < 8; y++) {
const px = Math.round(imageData.width * (0.0760416667 + 0.0623372396 * cx + 0.0065476190 * x));
const py = Math.round(imageData.height * (0.2342592593 + 0.1521122685 * cy + 0.0116931217 * y));
if (getR(px, py) >= 224)
col.push(getG(px, py) >= 224 ? Space.Ink1 : Space.SpecialInactive1);
else
col.push(Space.Empty);
}
grid.push(col);
}
// Find the card with this pattern.
function cardMatches(card: Card) {
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
if (card.grid[x][y] != grid[x][y]) return false;
}
}
return true;
}
const card = cardDatabase.cards.find(cardMatches);
if (!card) throw new Error(`Card number ${i + 1} is unknown.`);
cards.push(card.number);
}
return cards;
}

View File

@ -1250,6 +1250,15 @@ dialog::backdrop {
justify-content: center;
}
#deckImportForm, #deckImportTextSection:not([hidden]) {
display: flex;
flex-flow: column;
}
#deckImportDialog ol {
text-align: start;
}
/* Help */
#helpControls {