Enhance keyboard controls

The board can now receive focus.
Hand buttons - arrows: move focus; Space/Enter: select card
Board - arrows: move card; R: rotate clockwise; Shift+R: rotate anticlockwise; Space/Enter: submit play; Backspace/Escape: deselect card
Either - p: toggle pass; s: toggle special attack
This commit is contained in:
Andrio Celos
2022-10-06 10:58:41 +11:00
parent 1d3a3017f7
commit 3b4e1a865f
5 changed files with 182 additions and 17 deletions

View File

@@ -103,7 +103,7 @@
</div>
</div>
<div id="boardSection">
<table id="gameBoard"></table>
<table id="gameBoard" tabindex="0"></table>
<div id="redrawModal" hidden>
<div id="redrawBox">
<p>Redraw your starting hand?</p>

View File

@@ -14,13 +14,63 @@ class Board {
highlightY = NaN;
private highlightedCells: [x: number, y: number][] = [ ];
startSpaces: { x: number, y: number }[] = [ ];
onclick: ((x: number, y: number) => void) | null = null;
oncancel: (() => void) | null = null;
constructor(table: HTMLTableElement) {
this.table = table;
table.addEventListener('mouseleave', _ => {
if (this.autoHighlight) this.clearHighlight()
});
table.addEventListener('keydown', e => {
if (this.autoHighlight) {
switch (e.key) {
case 'r':
this.moveHighlight((x, y, r) => [x, y, r + 1], false);
break;
case 'R':
this.moveHighlight((x, y, r) => [x, y, r - 1], false);
break;
case 'ArrowUp':
this.moveHighlight((x, y, r) => [x, y - 1, r], true);
break;
case 'ArrowDown':
this.moveHighlight((x, y, r) => [x, y + 1, r], true);
break;
case 'ArrowLeft':
this.moveHighlight((x, y, r) => [x - 1, y, r], true);
break;
case 'ArrowRight':
this.moveHighlight((x, y, r) => [x + 1, y, r], true);
break;
case 'Enter': case ' ':
if (this.onclick)
this.onclick(this.highlightX, this.highlightY);
break;
case 'Escape': case 'Backspace':
if (this.oncancel)
this.oncancel();
break;
}
}
})
}
moveHighlight(move: (x: number, y: number, r: number) => [number, number, number], clamp: boolean) {
if (this.playerIndex == null) return;
if (this.highlightedCells.length == 0 || isNaN(this.highlightX) || isNaN(this.highlightY) || isNaN(this.cardRotation)) {
const startSpace = this.startSpaces[this.playerIndex];
[this.highlightX, this.highlightY, this.cardRotation] = [startSpace.x - 3, startSpace.y - 3, 0];
} else {
let [x, y, r] = move(this.highlightX, this.highlightY, this.cardRotation);
let clampedPosition = clamp
? this.cardPlaying!.clampPosition(x, y, this.grid.length, this.grid[0].length, r)
: { x, y };
[this.highlightX, this.highlightY, this.cardRotation] = [clampedPosition.x, clampedPosition.y, r];
}
this.refreshHighlight();
}
checkMoveLegality(playerIndex: number, card: Card, x: number, y: number, rotation: number, isSpecialAttack: boolean): string | null {
@@ -133,7 +183,7 @@ class Board {
trs[y].appendChild(td);
col.push(td);
td.addEventListener('mousemove', e => {
if (this.autoHighlight) {
if (this.autoHighlight && this.cardPlaying != null) {
const x = parseInt((e.target as HTMLTableCellElement).dataset.x!) - 3;
const y = parseInt((e.target as HTMLTableCellElement).dataset.y!) - 3;
if (x != this.highlightX || y != this.highlightY) {
@@ -144,14 +194,14 @@ class Board {
}
});
td.addEventListener('click', e => {
if (this.autoHighlight && this.onclick) {
if (this.autoHighlight && this.cardPlaying != null && this.onclick) {
const x = parseInt((e.target as HTMLTableCellElement).dataset.x!) - 3;
const y = parseInt((e.target as HTMLTableCellElement).dataset.y!) - 3;
this.onclick(x, y);
}
});
td.addEventListener('wheel', e => {
if (this.autoHighlight) {
if (this.autoHighlight && this.cardPlaying != null) {
e.preventDefault();
if (e.deltaY > 0) this.cardRotation++;
else if (e.deltaY < 0) this.cardRotation--;

View File

@@ -6,6 +6,11 @@ class Card {
grid: Space[][];
size: number;
private minX: number;
private minY: number;
private maxX: number;
private maxY: number;
constructor(number: number, name: string, rarity: Rarity, specialCost: number, grid: Space[][]) {
this.number = number;
this.name = name;
@@ -13,14 +18,23 @@ class Card {
this.specialCost = specialCost;
this.grid = grid;
let size = 0;
let size = 0, minX = 3, minY = 3, maxX = 3, maxY = 3;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
if (grid[x][y] != Space.Empty)
if (grid[x][y] != Space.Empty) {
size++;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
this.size = size;
this.minX = minX;
this.minY = minY;
this.maxX = maxX;
this.maxY = maxY;
}
static fromJson(obj: any) {
@@ -35,4 +49,17 @@ class Card {
default: return this.grid[7 - y][x];
}
}
clampPosition(x: number, y: number, boardWidth: number, boardHeight: number, rotation: number) {
let x1, y1, x2, y2;
switch (rotation & 3) {
case 0: x1 = this.minX; y1 = this.minY; x2 = this.maxX; y2 = this.maxY; break;
case 1: x1 = 7 - this.maxY; y1 = this.minX; x2 = 7 - this.minY; y2 = this.maxX; break;
case 2: x1 = 7 - this.maxX; y1 = 7 - this.maxY; x2 = 7 - this.minX; y2 = 7 - this.minY; break;
default: x1 = this.minY; y1 = 7 - this.maxX; x2 = this.maxY; y2 = 7 - this.minX; break;
}
x = Math.min(Math.max(x, -x1), boardWidth - 1 - x2);
y = Math.min(Math.max(y, -y1), boardHeight - 1 - y2);
return { x, y };
}
}

View File

@@ -28,8 +28,10 @@ for (let x = 0; x < 9; x++) {
col.push(Space.Empty);
cols.push(col);
}
cols[4][4] = Space.SpecialInactive2;
board.startSpaces.push({ x: 4, y: 21 });
board.startSpaces.push({ x: 4, y: 4 });
cols[4][21] = Space.SpecialInactive1;
cols[4][4] = Space.SpecialInactive2;
board.resize(cols);
function loadPlayers(players: Player[]) {
@@ -102,8 +104,9 @@ async function playInkAnimations(data: {
const inkPlaced = new Set<number>();
const placements = data.placements;
board.clearHighlight();
canPlay = false;
board.cardPlaying = null;
board.autoHighlight = false;
canPlay = false;
await delay(anySpecialAttacks ? 3000 : 1000);
for (const placement of placements) {
// Skip the delay when cards don't overlap.
@@ -184,11 +187,12 @@ function updateHand(cards: any[]) {
if (!currentGame?.me) return;
currentGame.me.hand = cards.map(Card.fromJson);
if (cards) {
for (const card of currentGame.me.hand) {
for (let i = 0; i < currentGame.me.hand.length; i++) {
const card = currentGame.me.hand[i];
const button = new CardButton('radio', card);
button.inputElement.name = 'handCard';
handButtons.push(button);
button.inputElement.addEventListener('change', e => {
button.inputElement.addEventListener('input', e => {
if (button.checked) {
for (const button2 of handButtons) {
if (button2 != button)
@@ -208,9 +212,40 @@ function updateHand(cards: any[]) {
board.autoHighlight = false;
}
} else {
board.cardPlaying = card;
if (isNaN(board.highlightX) || isNaN(board.highlightY)) {
board.highlightX = board.startSpaces[board.playerIndex!].x - 3;
board.highlightY = board.startSpaces[board.playerIndex!].y - 3;
}
board.cardRotation = 0;
board.refreshHighlight();
board.table.focus();
}
board.cardPlaying = card;
board.cardRotation = 0;
}
});
button.inputElement.addEventListener('keydown', e => {
switch (e.key) {
case 'ArrowUp':
if (i >= 2)
handButtons[i - 2].inputElement.focus();
e.preventDefault();
break;
case 'ArrowDown':
if (i < 2)
handButtons[i + 2].inputElement.focus();
e.preventDefault();
break;
case 'ArrowLeft':
if (i % 2 != 0)
handButtons[i - 1].inputElement.focus();
e.preventDefault();
break;
case 'ArrowRight':
if (i % 2 == 0)
handButtons[i + 1].inputElement.focus();
e.preventDefault();
break;
}
});
handContainer.appendChild(button.element);
@@ -230,12 +265,13 @@ function redrawButton_click(e: MouseEvent) {
redrawModal.hidden = true;
}
passButton.addEventListener('change', e => {
function passButton_input() {
board.autoHighlight = !passButton.checked;
if (passButton.checked) {
specialButton.checked = false;
board.cardPlaying = null;
board.specialAttack = false;
board.clearHighlight();
for (const el of handButtons) {
el.enabled = true;
el.checked = false;
@@ -245,8 +281,10 @@ passButton.addEventListener('change', e => {
handButtons[i].enabled = canPlayCard[i];
}
}
});
specialButton.addEventListener('change', e => {
}
passButton.addEventListener('input', passButton_input);
function specialButton_input() {
board.specialAttack = specialButton.checked;
if (specialButton.checked) {
passButton.checked = false;
@@ -263,7 +301,8 @@ specialButton.addEventListener('change', e => {
handButtons[i].checked = false;
}
}
});
}
specialButton.addEventListener('input', specialButton_input);
board.onclick = (x, y) => {
if (board.cardPlaying == null || !currentGame?.me)
@@ -308,6 +347,55 @@ board.onclick = (x, y) => {
}
};
board.oncancel = () => {
board.cardPlaying = null;
board.clearHighlight();
for (const button of handButtons) {
if (button.checked) {
button.checked = false;
button.inputElement.focus();
}
}
};
function focusFirstEnabledHandCard() {
const input = document.activeElement as HTMLInputElement;
if (input.name == 'handCard') {
if (input.disabled)
input.checked = false;
else {
// An enabled hand button is already active; don't move the focus.
return;
}
}
const firstEnabledButton = handButtons.find(b => b.enabled);
if (firstEnabledButton)
firstEnabledButton.inputElement.focus();
}
document.addEventListener('keydown', e => {
if (!sections.get('game')!.hidden) {
switch (e.key) {
case 'p':
if (!passButton.disabled) {
passButton.checked = !passButton.checked;
passButton_input();
focusFirstEnabledHandCard();
}
e.preventDefault();
break;
case 's':
if (!specialButton.disabled) {
specialButton.checked = !specialButton.checked;
specialButton_input();
focusFirstEnabledHandCard();
}
e.preventDefault();
break;
}
}
});
document.getElementById('resultLeaveButton')!.addEventListener('click', e => {
e.preventDefault();
document.getElementById('preGameDefaultSection')!.hidden = false;

View File

@@ -179,7 +179,7 @@ footer {
z-index: -2;
opacity: 0.25;
}
.card:hover:not(.checked):not(.disabled)::before {
.card:is(:hover, :focus-within):not(.checked, .disabled)::before {
content: '';
background: grey;
}