switch to using templates for displaying game state

main
Inga 🏳‍🌈 2 weeks ago
parent 664197cb72
commit 1ae6d5c8b0
  1. 38
      README.md
  2. 110
      src/backend/components/boardgame.tsx
  3. 31
      src/frontend/components/board-game.ts
  4. 13
      src/frontend/static/style.css
  5. 36
      src/shared/display.ts

@ -308,6 +308,9 @@ The main required features are:
without them this demo will fall back to `mode 3`.
* Custom built-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is,
without them this demo will fall back to `mode 2`.
* Templates: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template,
without them `mode 1` or `mode 2` will break, but they're in baseline HTML,
so if they're not supported, ES modules are not supported either, and this demo will fall back to `mode 3` anyway.
* `:scope` CSS selector, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/CSS/:scope
* `MutationObserver`, needed in `mode 2` only:
@ -315,6 +318,7 @@ The main required features are:
* `:not` CSS selector for critical UI features, needed in all modes
(hiding and showing different messages and buttons depending on the game state):
https://developer.mozilla.org/en-US/docs/Web/CSS/:not.
* `hidden` attribute or inline `style="display: none"` support in `mode 3` to not display templates.
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project).
@ -346,33 +350,30 @@ Therefore, for desktop browsers:
UI should be functional (not pretty) in IE8+ (2008) and dysfunctional in IE7 and lower
(fixable by refactoring the approach to displaying/hiding elements to avoid the usage of `:not` selector,
but this is outside the scope of the task).
* links: checked on 2.30, half-broken:
* Everything that depends on the game state is always displayed,
i.e. we get "Start game", "Restart game", "make computer play for X",
"make X moves manually", and most importantly,
"Player X won", "Player Y won" and "Draw" all displayed always;
this can be worked around by using Web Components templates
instead of controlling state visibility by CSS.
* Additionally, Links doesn't support disabled buttons
* links: checked on 2.30, mostly functional:
* Links doesn't support disabled buttons
so all board fields are always clickable (even if they're already filled),
this does not cause any functional issues (clicking on the occupied field,
or clicking on an empty field when the game is over, does not change anything),
but still that's a poor UX.
* elinks: checked on 0.17.1.1, half broken in the same way as on links, not easily fixable
because even with Web Components templates, elinks will still display template content:
https://github.com/rkd77/elinks/issues/341.
Fixable by building all state-dependent components on frontend manually
(so that backend only sends those that are relevant for the current state),
but that would be too significant redesign for no benefit besides making things work in elinks.
* elinks: checked on 0.17.1.1, mostly functional:
* elinks just like links does not support disabled buttons in 0.17.1.1,
but this is fixed in https://github.com/rkd77/elinks/issues/341.
* elinks does not support `hidden` attribute and by default does not support `display: none` in 0.17.1.1,
so it will display the content of all (unused) templates.
Support for `hidden` attribute is already implemented in https://github.com/rkd77/elinks/issues/341,
and will automatically work in newer versions; in older versions, the user should add
`set document.css.ignore_display_none = 0` to their elinks config.
So presumably, according to caniuse.com,
* `mode 1` should work in 88% desktop browsers and 80% total browsers
(with Safari being the notable exception contributing 9% on desktop and 18% overall);
* Fallback to `mode 2` should bring the total to at least 96% overall (98% on desktop; for some less popular mobile browsers support status is unknown);
* Fallback to `mode 2` (which is just as functional but with a bit more convoluted JS code)
should bring the total to at least 96% overall (98% on desktop; for some less popular mobile browsers support status is unknown);
* The demo should at least fall back to functional `mode 3` (client-server) with the functional UI
in 99.9% desktop and presumably 99.9% overall browsers,
with Internet Explorer being the main offender.
in all more or less mainstream browsers (including presumably Internet Explorer 6 and NetFront 3, unchecked),
and in some non-mainstream ones, including text browsers such as links and elinks (checked) but notably not lynx.
## Known issues and quirks
@ -387,9 +388,6 @@ So presumably, according to caniuse.com,
## Remaining tasks
* Use HTML `<template>`s for rendering state messages,
instead of having them all in HTML and then hiding irrelevant with CSS,
for better graceful degradation in browsers that don't support complex CSS queries with `:not`.
* Implement error handling and handling of incorrect / malformed game states (since they come from the client).
* Improve UI for large boards (disable autoplayers when board is too large,
hide "enable autoplayer" buttons, provide clear indication to the user instead).

@ -1,6 +1,12 @@
import { sequence } from "../../shared/utils/array-utils.ts";
import { BoardgameStateType, CurrentOutcome } from "../../shared/datatypes/types.ts";
import { ButtonValues, getButtonValues, getCellDisplayData, getDisplayStates } from "../../shared/display.ts";
import {
ButtonValues,
getButtonValues,
getCellDisplayData,
getDisplayStates,
getSlotTemplateNames,
} from "../../shared/display.ts";
import { GameVariantName, gamesRules } from "../../shared/game-variants/index.ts";
const getSubmitAttributes = (key: string, targetState: BoardgameStateType) => ({
@ -46,6 +52,55 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
const buttonValues = getButtonValues(gameState);
const templates = {
"state-draw": "Draw",
"state-win-x": "Player X won",
"state-win-o": "Player O won",
"state-move-x": "It's X move",
"state-move-o": "It's O move",
"state-uninitialized": "",
"configuration-x-autoplayer": (
<>
Currently X moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-disable", { key, buttonValues })}>
Make them manually.
</button>
</>
),
"configuration-x-manual": (
<>
Currently X moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-enable", { key, buttonValues })}>
Make computer play for X.
</button>
</>
),
"configuration-o-autoplayer": (
<>
Currently O moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-disable", { key, buttonValues })}>
Make them manually.
</button>
</>
),
"configuration-o-manual": (
<>
Currently O moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-enable", { key, buttonValues })}>
Make computer play for O.
</button>
</>
),
};
const slotTemplateNames = getSlotTemplateNames(gameState, currentOutcome);
const getSlotParams = (slotName: keyof typeof slotTemplateNames) => ({
class: slotName,
"data-current-template": slotTemplateNames[slotName],
children: [templates[slotTemplateNames[slotName]]],
});
const gameClasses = getDisplayStates(gameState, currentOutcome);
const gameActiveClassNames = Object.entries(gameClasses)
.filter(([, value]) => value)
@ -55,10 +110,6 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
<board-game data-track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}>
<progressive-form-wrapper>
<form method="post" is="progressive-form">
<p class="when-outcome-undecided">
Current player: <span class="current-player-name">{gameState.currentPlayerName}</span>
</p>
<table class="game-board-table">
<tbody class="game-board">
{sequence(gameState.rows).map((row) => (
@ -71,44 +122,14 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
</tbody>
</table>
<p>
<span class="when-outcome-winx">Player X won</span>
<span class="when-outcome-wino">Player O won</span>
<span class="when-outcome-draw">Draw</span>
</p>
<p class="when-autoplayer-x-disabled">
Currently X moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-enable", { key, buttonValues })}>
Make computer play for X.
</button>
</p>
<p {...getSlotParams("state-slot")} />
<p class="when-autoplayer-x-enabled">
Currently X moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p class="when-autoplayer-o-disabled">
Currently O moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-enable", { key, buttonValues })}>
Make computer play for O.
</button>
</p>
<p class="when-autoplayer-o-enabled">
Currently O moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p {...getSlotParams("configuration-x-slot")} />
<p {...getSlotParams("configuration-o-slot")} />
<p>
<button {...getGenericButtonAttributes("game-start", { key, buttonValues })}>
<span class="when-game-not-in-progress">Start game</span>
<span class="when-game-in-progress">Restart game</span>
<span>Start new game</span>
</button>
</p>
<p>
@ -123,6 +144,17 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
</p>
</form>
</progressive-form-wrapper>
<div hidden style="display: none">
Looks like your browser does not support neither `hidden` HTML attribute nor (very old) inline `style="display:
none"` CSS (are you using lynx or very old links?) So be a good bean and do the browser job yourself by ignoring
this text and what comes next. (Also if you're on elinks 0.17.1 or earlier, adding `set
document.css.ignore_display_none = 0` to your elinks config might help.)
{Object.entries(templates).map(([key, value]) => (
<template name={key}>{value}</template>
))}
OK now you can stop ignoring.
</div>
</board-game>
);
};

@ -2,7 +2,13 @@ import type {} from "typed-query-selector/strict.d.ts";
import { BoardgameState } from "../../shared/datatypes/boardgame-state.ts";
import { getTargetGameState } from "../../shared/gameplay/boardgame.ts";
import { CurrentOutcome } from "../../shared/datatypes/types.ts";
import { ButtonValues, getButtonValues, getCellDisplayData, getDisplayStates } from "../../shared/display.ts";
import {
ButtonValues,
getButtonValues,
getCellDisplayData,
getDisplayStates,
getSlotTemplateNames,
} from "../../shared/display.ts";
import { GameVariantName, gamesRules } from "../../shared/game-variants/index.ts";
import { replaceLocation } from "../utils/navigation-utils.ts";
import { TrackingTools } from "../utils/query-tracking-utils.ts";
@ -25,6 +31,27 @@ class BoardGameComponent extends HTMLElement {
return;
}
const currentOutcome = gameState.board ? rules.getBoardOutcome(gameState.board) : CurrentOutcome.Undecided;
const slotTemplateNames = getSlotTemplateNames(gameState, currentOutcome);
// Technically we're mixing code and data here, but that's OK
// because both slotName and templateName originate from getSlotTemplateNames and is not actually arbitrary
for (const [slotName, templateName] of Object.entries(slotTemplateNames)) {
this.querySelectorAll(`.${slotName}`).forEach((slot) => {
if (slot.getAttribute("data-current-template") !== templateName) {
const template = this.querySelector(`template[name="${templateName}"]`);
if (template) {
while (slot.lastChild) {
slot.removeChild(slot.lastChild);
}
slot.appendChild(template.content.cloneNode(true));
slot.setAttribute("data-current-template", templateName);
}
}
});
}
this.querySelectorAll(".current-player-name").forEach((playerNameElement) => {
if ((playerNameElement as HTMLElement).innerText !== gameState.currentPlayerName) {
(playerNameElement as HTMLElement).innerText = gameState.currentPlayerName;
@ -40,8 +67,6 @@ class BoardGameComponent extends HTMLElement {
});
});
const currentOutcome = gameState.board ? rules.getBoardOutcome(gameState.board) : CurrentOutcome.Undecided;
const displayStates = getDisplayStates(gameState, currentOutcome);
for (const [className, shouldEnable] of Object.entries(displayStates)) {
this.classList.toggle(className, shouldEnable);

@ -30,19 +30,6 @@ li form {
display: inline;
}
board-game:not(.outcome-winx) .when-outcome-winx,
board-game:not(.outcome-wino) .when-outcome-wino,
board-game:not(.outcome-draw) .when-outcome-draw,
board-game:not(.outcome-undecided) .when-outcome-undecided,
board-game:not(.autoplayer-x-enabled) .when-autoplayer-x-enabled,
board-game:not(.autoplayer-x-disabled) .when-autoplayer-x-disabled,
board-game:not(.autoplayer-o-enabled) .when-autoplayer-o-enabled,
board-game:not(.autoplayer-o-disabled) .when-autoplayer-o-disabled,
board-game:not(.game-in-progress) .when-game-in-progress,
board-game:not(.game-not-in-progress) .when-game-not-in-progress {
display: none;
}
table.game-board-table {
border: solid 7px transparent;
border-collapse: collapse;

@ -1,4 +1,5 @@
import { BoardgameStateType, CurrentOutcome, Player, SquareState, formatSquareState } from "./datatypes/types.ts";
import { unreachableString } from "./utils/utils.ts";
export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => ({
"outcome-winx": currentOutcome === CurrentOutcome.WinX,
@ -61,3 +62,38 @@ export const getButtonValues = (gameState: BoardgameStateType) => ({
});
export type ButtonValues = ReturnType<typeof getButtonValues>;
export const getGameStateTemplateName = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => {
switch (currentOutcome) {
case CurrentOutcome.Draw:
return "state-draw";
case CurrentOutcome.WinX:
return "state-win-x";
case CurrentOutcome.WinO:
return "state-win-o";
case CurrentOutcome.Undecided:
switch (gameState.currentPlayer) {
case Player.X:
return "state-move-x";
case Player.O:
return "state-move-o";
case null:
return "state-uninitialized";
default:
throw new Error(`Unsupported current player: ${unreachableString(gameState.currentPlayer)}`);
}
default:
throw new Error(`Unsupported current outcome: ${unreachableString(currentOutcome)}`);
}
};
export const getSlotTemplateNames = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) =>
({
"state-slot": getGameStateTemplateName(gameState, currentOutcome),
"configuration-x-slot": gameState.autoPlayers.has(Player.X)
? "configuration-x-autoplayer"
: "configuration-x-manual",
"configuration-o-slot": gameState.autoPlayers.has(Player.O)
? "configuration-o-autoplayer"
: "configuration-o-manual",
}) as const;

Loading…
Cancel
Save