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`. 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, * 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`. 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: * `:scope` CSS selector, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/CSS/:scope https://developer.mozilla.org/en-US/docs/Web/CSS/:scope
* `MutationObserver`, needed in `mode 2` only: * `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 * `:not` CSS selector for critical UI features, needed in all modes
(hiding and showing different messages and buttons depending on the game state): (hiding and showing different messages and buttons depending on the game state):
https://developer.mozilla.org/en-US/docs/Web/CSS/:not. 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 * 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). (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 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, (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). but this is outside the scope of the task).
* links: checked on 2.30, half-broken: * links: checked on 2.30, mostly functional:
* Everything that depends on the game state is always displayed, * Links doesn't support disabled buttons
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
so all board fields are always clickable (even if they're already filled), 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, 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), or clicking on an empty field when the game is over, does not change anything),
but still that's a poor UX. 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 * elinks: checked on 0.17.1.1, mostly functional:
because even with Web Components templates, elinks will still display template content: * elinks just like links does not support disabled buttons in 0.17.1.1,
https://github.com/rkd77/elinks/issues/341. but this is fixed in https://github.com/rkd77/elinks/issues/341.
Fixable by building all state-dependent components on frontend manually * elinks does not support `hidden` attribute and by default does not support `display: none` in 0.17.1.1,
(so that backend only sends those that are relevant for the current state), so it will display the content of all (unused) templates.
but that would be too significant redesign for no benefit besides making things work in elinks. 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, So presumably, according to caniuse.com,
* `mode 1` should work in 88% desktop browsers and 80% total browsers * `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); (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 * 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, in all more or less mainstream browsers (including presumably Internet Explorer 6 and NetFront 3, unchecked),
with Internet Explorer being the main offender. and in some non-mainstream ones, including text browsers such as links and elinks (checked) but notably not lynx.
## Known issues and quirks ## Known issues and quirks
@ -387,9 +388,6 @@ So presumably, according to caniuse.com,
## Remaining tasks ## 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). * 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, * Improve UI for large boards (disable autoplayers when board is too large,
hide "enable autoplayer" buttons, provide clear indication to the user instead). hide "enable autoplayer" buttons, provide clear indication to the user instead).

@ -1,6 +1,12 @@
import { sequence } from "../../shared/utils/array-utils.ts"; import { sequence } from "../../shared/utils/array-utils.ts";
import { BoardgameStateType, CurrentOutcome } from "../../shared/datatypes/types.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"; import { GameVariantName, gamesRules } from "../../shared/game-variants/index.ts";
const getSubmitAttributes = (key: string, targetState: BoardgameStateType) => ({ const getSubmitAttributes = (key: string, targetState: BoardgameStateType) => ({
@ -46,6 +52,55 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
const buttonValues = getButtonValues(gameState); 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 gameClasses = getDisplayStates(gameState, currentOutcome);
const gameActiveClassNames = Object.entries(gameClasses) const gameActiveClassNames = Object.entries(gameClasses)
.filter(([, value]) => value) .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}> <board-game data-track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}>
<progressive-form-wrapper> <progressive-form-wrapper>
<form method="post" is="progressive-form"> <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"> <table class="game-board-table">
<tbody class="game-board"> <tbody class="game-board">
{sequence(gameState.rows).map((row) => ( {sequence(gameState.rows).map((row) => (
@ -71,44 +122,14 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
</tbody> </tbody>
</table> </table>
<p> <p {...getSlotParams("state-slot")} />
<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 class="when-autoplayer-x-enabled"> <p {...getSlotParams("configuration-x-slot")} />
Currently X moves are made by computer.{" "} <p {...getSlotParams("configuration-o-slot")} />
<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> <p>
<button {...getGenericButtonAttributes("game-start", { key, buttonValues })}> <button {...getGenericButtonAttributes("game-start", { key, buttonValues })}>
<span class="when-game-not-in-progress">Start game</span> <span>Start new game</span>
<span class="when-game-in-progress">Restart game</span>
</button> </button>
</p> </p>
<p> <p>
@ -123,6 +144,17 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
</p> </p>
</form> </form>
</progressive-form-wrapper> </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> </board-game>
); );
}; };

@ -2,7 +2,13 @@ import type {} from "typed-query-selector/strict.d.ts";
import { BoardgameState } from "../../shared/datatypes/boardgame-state.ts"; import { BoardgameState } from "../../shared/datatypes/boardgame-state.ts";
import { getTargetGameState } from "../../shared/gameplay/boardgame.ts"; import { getTargetGameState } from "../../shared/gameplay/boardgame.ts";
import { CurrentOutcome } from "../../shared/datatypes/types.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 { GameVariantName, gamesRules } from "../../shared/game-variants/index.ts";
import { replaceLocation } from "../utils/navigation-utils.ts"; import { replaceLocation } from "../utils/navigation-utils.ts";
import { TrackingTools } from "../utils/query-tracking-utils.ts"; import { TrackingTools } from "../utils/query-tracking-utils.ts";
@ -25,6 +31,27 @@ class BoardGameComponent extends HTMLElement {
return; 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) => { this.querySelectorAll(".current-player-name").forEach((playerNameElement) => {
if ((playerNameElement as HTMLElement).innerText !== gameState.currentPlayerName) { if ((playerNameElement as HTMLElement).innerText !== gameState.currentPlayerName) {
(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); const displayStates = getDisplayStates(gameState, currentOutcome);
for (const [className, shouldEnable] of Object.entries(displayStates)) { for (const [className, shouldEnable] of Object.entries(displayStates)) {
this.classList.toggle(className, shouldEnable); this.classList.toggle(className, shouldEnable);

@ -30,19 +30,6 @@ li form {
display: inline; 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 { table.game-board-table {
border: solid 7px transparent; border: solid 7px transparent;
border-collapse: collapse; border-collapse: collapse;

@ -1,4 +1,5 @@
import { BoardgameStateType, CurrentOutcome, Player, SquareState, formatSquareState } from "./datatypes/types.ts"; import { BoardgameStateType, CurrentOutcome, Player, SquareState, formatSquareState } from "./datatypes/types.ts";
import { unreachableString } from "./utils/utils.ts";
export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => ({ export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => ({
"outcome-winx": currentOutcome === CurrentOutcome.WinX, "outcome-winx": currentOutcome === CurrentOutcome.WinX,
@ -61,3 +62,38 @@ export const getButtonValues = (gameState: BoardgameStateType) => ({
}); });
export type ButtonValues = ReturnType<typeof getButtonValues>; 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