@johnmorrisdotca/houseki 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +479 -11
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to this package are written here, newest first, in the form
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.1] - 2026-10-06
|
|
8
|
+
|
|
9
|
+
Nothing that was exported has changed. The README is the family's one layout, in full.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The README has a picture of the demo on a desk and on a phone, in light and dark, taken from the demo by `pnpm screenshots:readme` (the pictures are in `docs/images/` and are not in the package), a picture of each game and campaign (Falling Triplets, Colour Chains, Gem Swap, Stone Collapse, Magnetic Blocks, Shizen and Arashi) and of the tool tray on a phone, an Examples section of nine examples that run, examples for React, Vue, Svelte and Angular, an Accessibility section, and tables of the entry points and the calls to learn first.
|
|
14
|
+
- `pnpm test:readme` type-checks and runs every TypeScript and JavaScript example in the README against the built package, as a job of its own in CI; `src/readme.test.js` holds the README to the family's standard (sections in order, languages on code fences, pictures with alt text and a caption, no marketing words, version pins) in `pnpm check`; `pnpm test:package` fails if a picture or anything under `docs/` is in the packed package.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- On a phone the page's title, "Magnetic Blocks" in English, takes the whole row of its panel head and does not wrap, so that the board under it sits in the same place in English and in Japanese in any font. The browser check that holds this failed on GitHub's runners, whose fonts are wider than a Mac's; nothing the package exports has changed.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `pnpm screenshots:readme` takes the README's pictures as WebP in light and dark under `docs/images/`, instead of five PNGs of 3 MB taken by hand.
|
|
23
|
+
|
|
7
24
|
## [0.2.0] - 2026-10-06
|
|
8
25
|
|
|
9
26
|
### Added
|
package/README.md
CHANGED
|
@@ -11,12 +11,25 @@ Falling Triplets, Colour Chains, Stone Collapse and Gem Swap: each an immutable,
|
|
|
11
11
|
<img alt="TypeScript" src="https://img.shields.io/badge/types-TypeScript-2f5d4a">
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
<
|
|
14
|
+
<table align="center">
|
|
15
|
+
<tr>
|
|
16
|
+
<td align="center" valign="top">
|
|
17
|
+
<picture>
|
|
18
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/hero-desk-dark.webp">
|
|
19
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/hero-desk-light.webp" alt="The demo on a desk, in English: the page header with its language chooser, the API reference link, five cloth patches and the Help switch, the page chooser (Falling Triplets, Colour Chains, Gem Swap and Stone Collapse chosen, Magnetic Blocks), the settings column, and Gem Swap on green felt: an eight by eight board of red, blue, green, gold and purple gems with a permanent symbol on each, and the tool tray beside it with Bomb, Row clear and Colour clear" width="600">
|
|
20
|
+
</picture>
|
|
21
|
+
<br><em>The demo on a desk: Gem Swap with its tool tray.</em>
|
|
22
|
+
</td>
|
|
23
|
+
<td align="center" valign="top">
|
|
24
|
+
<picture>
|
|
25
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/hero-phone-dark.webp">
|
|
26
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/hero-phone-light.webp" alt="The demo on a phone, in Japanese: the Gem Swap board of gems with their symbols, and under it the tool tray, 道具箱, with the bomb, row and colour tools, the confirm and cancel buttons, and the hint and reshuffle buttons" width="190">
|
|
27
|
+
</picture>
|
|
28
|
+
<br><em>On a phone, in Japanese, in the device's light or dark.</em>
|
|
29
|
+
</td>
|
|
30
|
+
</tr>
|
|
31
|
+
</table>
|
|
15
32
|
|
|
16
|
-
<p align="center">
|
|
17
|
-
<img src="docs/images/gem-swap-desktop.png" alt="Gem Swap on a full board in the demo: a grid of red, blue, green, gold, purple and teal gems with permanent symbols on them, the tool tray beside it with Bomb, Row clear and Colour clear, and the family's header above" width="620">
|
|
18
|
-
<img src="docs/images/tool-tray-phone.png" alt="The same tool tray on a phone, under the board" width="200">
|
|
19
|
-
</p>
|
|
20
33
|
|
|
21
34
|
Houseki is a set of match-style puzzle games made to be dropped into a page or a server. Every game is a set of plain functions on plain data: a state in, a new state out, nothing read from the page, the clock or the network. The demo's players draw those states, and a person can play them with the keyboard, a keypad or a finger.
|
|
22
35
|
|
|
@@ -86,8 +99,91 @@ Or straight from a page, with nothing to build:
|
|
|
86
99
|
| Shizen | `colour-chains` | anticipate marked stones and rebound | four connected gems after attraction | 50 |
|
|
87
100
|
| Arashi | `colour-chains` | plan around earthquakes and lightning | four connected gems after weather | 50 |
|
|
88
101
|
|
|
102
|
+
|
|
103
|
+
### What's in it
|
|
104
|
+
|
|
105
|
+
Each picture is the real game, drawn by the demo's player from the package's engine and taken with `pnpm screenshots:readme`, in light and dark. The board is the same seed every time, so the pictures are the same each run.
|
|
106
|
+
|
|
107
|
+
<table>
|
|
108
|
+
<tr>
|
|
109
|
+
<td align="center" valign="top" width="50%">
|
|
110
|
+
<picture>
|
|
111
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/falling-triplets-desk-dark.webp">
|
|
112
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/falling-triplets-desk-light.webp" alt="Falling Triplets on a desk after six placed triplets: a tall pale well in a wooden frame with a heap of gems at the bottom, each with a permanent symbol, the Next column of three triplets beside it, the line Place the triplet and the buttons for the arrows, Reverse cycle, Cycle, Place and Pause under it" width="360">
|
|
113
|
+
</picture>
|
|
114
|
+
<br><em><strong>Falling Triplets.</strong> Cycle the colours of a falling vertical triplet and place it; three in a line, across, down or diagonal, clear.</em>
|
|
115
|
+
</td>
|
|
116
|
+
<td align="center" valign="top" width="50%">
|
|
117
|
+
<picture>
|
|
118
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/colour-chains-desk-dark.webp">
|
|
119
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/colour-chains-desk-light.webp" alt="Colour Chains on a desk after six placed pairs: a tall pale well with a heap of gems at the bottom and a pair of gems at the top, the Next pair beside it, and the buttons for the arrows, Rotate left, Rotate right, Place and Pause under it" width="360">
|
|
120
|
+
</picture>
|
|
121
|
+
<br><em><strong>Colour Chains.</strong> Rotate a falling pair; four or more of a colour connected clear, and the stones above fall and may clear again.</em>
|
|
122
|
+
</td>
|
|
123
|
+
</tr>
|
|
124
|
+
<tr>
|
|
125
|
+
<td align="center" valign="top" width="50%">
|
|
126
|
+
<picture>
|
|
127
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/gem-swap-desk-dark.webp">
|
|
128
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/gem-swap-desk-light.webp" alt="Gem Swap on a desk: an eight by eight board of gems in a wooden frame with the top-left gem selected, and the Tool tray beside it with Bomb × 1, Row clear × 1 and Colour clear × 1, the counter 0/12 ordinary clears toward the next tool, and the Confirm, Cancel, Hint and Reshuffle buttons" width="400">
|
|
129
|
+
</picture>
|
|
130
|
+
<br><em><strong>Gem Swap.</strong> Swap two neighbours to make a run of three or more; stored tools are previewed, then confirmed or cancelled.</em>
|
|
131
|
+
</td>
|
|
132
|
+
<td align="center" valign="top" width="50%">
|
|
133
|
+
<picture>
|
|
134
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/stone-collapse-desk-dark.webp">
|
|
135
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/stone-collapse-desk-light.webp" alt="Stone Collapse on a desk: a board of gems in a wooden frame, ten rows of eight, and the Tool tray beside it with Bomb × 1 and Pick × 1, the counter 0/12 ordinary clears toward the next tool, and the Confirm and Cancel buttons, with the line Choose a group or a tool" width="400">
|
|
136
|
+
</picture>
|
|
137
|
+
<br><em><strong>Stone Collapse.</strong> Select a connected group of two or more stones of one colour, read the preview, and confirm.</em>
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
<tr>
|
|
141
|
+
<td align="center" valign="top" width="50%">
|
|
142
|
+
<picture>
|
|
143
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/magnetic-blocks-desk-dark.webp">
|
|
144
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/magnetic-blocks-desk-light.webp" alt="Magnetic Blocks, challenge 1, on a desk: a well with bonded two-by-two blocks of gems along the floor, a falling block at the top, the Next floor and Current floor chips showing Calm, the objective Marked gem 0/3, the Floor Switch panel with Calm, Pull and Cancel buttons, and the buttons for the arrows, Rotate left, Rotate right, Hard drop, Place and Pause" width="360">
|
|
145
|
+
</picture>
|
|
146
|
+
<br><em><strong>Magnetic Blocks.</strong> Bonded 2×2 squares, a floor that turns magnetic on a schedule, and a one-use Floor Switch.</em>
|
|
147
|
+
</td>
|
|
148
|
+
<td align="center" valign="top" width="50%">
|
|
149
|
+
<picture>
|
|
150
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/shizen-desk-dark.webp">
|
|
151
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/shizen-desk-light.webp" alt="Shizen challenge 1 on a desk: a Colour Chains well with a few gems at the bottom and a pair at the top, the objective Target gems cleared 0 / 3 with a progress bar, Pieces left: 3, and the Next pair beside it" width="360">
|
|
152
|
+
</picture>
|
|
153
|
+
<br><em><strong>Shizen.</strong> Colour Chains with magnetic stones and one bounded rebound: a finite queue, a goal and a count of pieces left.</em>
|
|
154
|
+
</td>
|
|
155
|
+
</tr>
|
|
156
|
+
<tr>
|
|
157
|
+
<td align="center" valign="top" width="50%">
|
|
158
|
+
<picture>
|
|
159
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/arashi-desk-dark.webp">
|
|
160
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/arashi-desk-light.webp" alt="Arashi challenge 1 on a desk: a Colour Chains well with a few gems at the bottom and a pair at the top, the objective Target gems cleared 0 / 1 with a progress bar, Pieces left: 4, and the Next pair beside it" width="360">
|
|
161
|
+
</picture>
|
|
162
|
+
<br><em><strong>Arashi.</strong> Colour Chains with weather: plan around earthquakes that jumble stones and lightning that removes exposed ones.</em>
|
|
163
|
+
</td>
|
|
164
|
+
<td align="center" valign="top" width="50%">
|
|
165
|
+
<picture>
|
|
166
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/tool-tray-phone-dark.webp">
|
|
167
|
+
<img src="https://raw.githubusercontent.com/johnmorrisdotca/houseki/main/docs/images/tool-tray-phone-light.webp" alt="The Gem Swap board and its tool tray on a phone: an eight by eight board of gems with their symbols, and under it the Tool tray with the Bomb × 1, Row clear × 1 and Colour clear × 1 buttons, the counter 0/12 ordinary clears toward the next tool, the Confirm, Cancel, Hint and Reshuffle buttons and the line Choose a gem or a tool" width="240">
|
|
168
|
+
</picture>
|
|
169
|
+
<br><em><strong>On a phone.</strong> The tool tray sits under the board, and every button is at least 44 pixels high.</em>
|
|
170
|
+
</td>
|
|
171
|
+
</tr>
|
|
172
|
+
</table>
|
|
173
|
+
|
|
89
174
|
## Use it in your project
|
|
90
175
|
|
|
176
|
+
### Install
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
npm install @johnmorrisdotca/houseki
|
|
180
|
+
# or: pnpm add @johnmorrisdotca/houseki
|
|
181
|
+
# or: yarn add @johnmorrisdotca/houseki
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
It is ES modules only, with its types included, and needs Node 22 or later outside a browser. A page with no bundler imports an engine from a CDN (`@0` is the major version): see the first example under [Examples](#examples).
|
|
185
|
+
|
|
186
|
+
|
|
91
187
|
Import a game directly from `@johnmorrisdotca/houseki/falling-triplets`, `@johnmorrisdotca/houseki/stone-collapse`, `@johnmorrisdotca/houseki/colour-chains`, `@johnmorrisdotca/houseki/gem-swap` or `@johnmorrisdotca/houseki/magnetic-blocks`, and the deterministic attraction, rebound and environment utilities from `@johnmorrisdotca/houseki/nature`. The main entry, `@johnmorrisdotca/houseki`, groups the same names as the namespaces `fallingTriplets`, `stoneCollapse`, `colourChains`, `gemSwap`, `nature` and `magneticBlocks`. The engines do not touch the DOM, storage or the network.
|
|
92
188
|
|
|
93
189
|
Every engine works the same way:
|
|
@@ -109,7 +205,6 @@ To run the whole demo, `pnpm install && pnpm site`, then serve `site/` with any
|
|
|
109
205
|
|
|
110
206
|
### Additional campaigns
|
|
111
207
|
|
|
112
|
-

|
|
113
208
|
|
|
114
209
|
```ts
|
|
115
210
|
import { createShizenLevel, createArashiLevel } from "@johnmorrisdotca/houseki/colour-chains";
|
|
@@ -122,10 +217,364 @@ const bondedSquares = createLevel(1);
|
|
|
122
217
|
|
|
123
218
|
Each campaign has fifty fixed puzzles ordered from easy to hard. Use its manifest for stable IDs, bilingual titles, objectives, difficulty marks and verified winning plans. Select Shizen or Arashi in the Colour Chains demo, or a challenge in Magnetic Blocks. A campaign uses a finite queue with no falling clock; the goal and progress appear beside the board. Fixed rules stay visible, and the result offers replay or the next challenge. See the [campaign contract](docs/design/CAMPAIGN-EXTENSIONS.md).
|
|
124
219
|
|
|
220
|
+
### In a framework
|
|
221
|
+
|
|
222
|
+
The engines are plain functions with no framework code, so each framework holds a state and replaces it with what `applyAction` returns. Each of these is a Gem Swap board of buttons; the engine does the rules, and `advanceTicks` lets a swap settle.
|
|
223
|
+
|
|
224
|
+
#### React
|
|
225
|
+
|
|
226
|
+
```jsx
|
|
227
|
+
import { useState } from "react";
|
|
228
|
+
import { advanceTicks, applyAction, createGame, legalActions } from "@johnmorrisdotca/houseki/gem-swap";
|
|
229
|
+
|
|
230
|
+
export function Board() {
|
|
231
|
+
const [game, setGame] = useState(() => createGame({ seed: "hello" }));
|
|
232
|
+
const [chosen, setChosen] = useState(null);
|
|
233
|
+
const press = (index) => {
|
|
234
|
+
const swap = legalActions(game).find((a) => a.kind === "swap" && a.from === chosen && a.to === index);
|
|
235
|
+
if (swap) setGame(advanceTicks(applyAction(game, swap).state, 200).state);
|
|
236
|
+
setChosen(swap ? null : index);
|
|
237
|
+
};
|
|
238
|
+
return <div role="group" aria-label="Gem board" style={{ display: "grid", gridTemplateColumns: "repeat(8, 44px)" }}>
|
|
239
|
+
{game.board.map((gem, index) => <button key={gem.id} aria-pressed={chosen === index} onClick={() => press(index)}>{gem.colour}</button>)}
|
|
240
|
+
</div>;
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
#### Vue
|
|
245
|
+
|
|
246
|
+
```vue
|
|
247
|
+
<script setup>
|
|
248
|
+
import { ref, shallowRef } from "vue";
|
|
249
|
+
import { advanceTicks, applyAction, createGame, legalActions } from "@johnmorrisdotca/houseki/gem-swap";
|
|
250
|
+
|
|
251
|
+
const game = shallowRef(createGame({ seed: "hello" }));
|
|
252
|
+
const chosen = ref(null);
|
|
253
|
+
function press(index) {
|
|
254
|
+
const swap = legalActions(game.value).find((a) => a.kind === "swap" && a.from === chosen.value && a.to === index);
|
|
255
|
+
if (swap) game.value = advanceTicks(applyAction(game.value, swap).state, 200).state;
|
|
256
|
+
chosen.value = swap ? null : index;
|
|
257
|
+
}
|
|
258
|
+
</script>
|
|
259
|
+
|
|
260
|
+
<template>
|
|
261
|
+
<div role="group" aria-label="Gem board" style="display: grid; grid-template-columns: repeat(8, 44px)">
|
|
262
|
+
<button v-for="(gem, index) in game.board" :key="gem.id" :aria-pressed="chosen === index" @click="press(index)">{{ gem.colour }}</button>
|
|
263
|
+
</div>
|
|
264
|
+
</template>
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
#### Svelte
|
|
268
|
+
|
|
269
|
+
```svelte
|
|
270
|
+
<script>
|
|
271
|
+
import { advanceTicks, applyAction, createGame, legalActions } from "@johnmorrisdotca/houseki/gem-swap";
|
|
272
|
+
|
|
273
|
+
let game = createGame({ seed: "hello" });
|
|
274
|
+
let chosen = null;
|
|
275
|
+
function press(index) {
|
|
276
|
+
const swap = legalActions(game).find((a) => a.kind === "swap" && a.from === chosen && a.to === index);
|
|
277
|
+
if (swap) game = advanceTicks(applyAction(game, swap).state, 200).state;
|
|
278
|
+
chosen = swap ? null : index;
|
|
279
|
+
}
|
|
280
|
+
</script>
|
|
281
|
+
|
|
282
|
+
<div role="group" aria-label="Gem board" style="display: grid; grid-template-columns: repeat(8, 44px)">
|
|
283
|
+
{#each game.board as gem, index (gem.id)}
|
|
284
|
+
<button aria-pressed={chosen === index} on:click={() => press(index)}>{gem.colour}</button>
|
|
285
|
+
{/each}
|
|
286
|
+
</div>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
#### Angular
|
|
290
|
+
|
|
291
|
+
```ts no-check
|
|
292
|
+
import { Component, signal } from "@angular/core";
|
|
293
|
+
import { advanceTicks, applyAction, createGame, legalActions } from "@johnmorrisdotca/houseki/gem-swap";
|
|
294
|
+
|
|
295
|
+
@Component({
|
|
296
|
+
selector: "app-board",
|
|
297
|
+
standalone: true,
|
|
298
|
+
template: `<div role="group" aria-label="Gem board" style="display: grid; grid-template-columns: repeat(8, 44px)">
|
|
299
|
+
@for (gem of game().board; track gem.id; let index = $index) {
|
|
300
|
+
<button [attr.aria-pressed]="chosen() === index" (click)="press(index)">{{ gem.colour }}</button>
|
|
301
|
+
}
|
|
302
|
+
</div>`,
|
|
303
|
+
})
|
|
304
|
+
export class BoardComponent {
|
|
305
|
+
game = signal(createGame({ seed: "hello" }));
|
|
306
|
+
chosen = signal<number | null>(null);
|
|
307
|
+
press(index: number) {
|
|
308
|
+
const swap = legalActions(this.game()).find((a) => a.kind === "swap" && a.from === this.chosen() && a.to === index);
|
|
309
|
+
if (swap) this.game.set(advanceTicks(applyAction(this.game(), swap).state, 200).state);
|
|
310
|
+
this.chosen.set(swap ? null : index);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Examples
|
|
316
|
+
|
|
317
|
+
Each example is a whole recipe: copy it and it works. The ones in TypeScript are run in CI against the built package (`pnpm test:readme`), so none of them is a guess, and the output shown is what they print.
|
|
318
|
+
|
|
319
|
+
### A Gem Swap board in a page, with no bundler
|
|
320
|
+
|
|
321
|
+
The engine draws nothing, so a page draws the state. Save this as a file and serve it: an eight-by-eight board of buttons, press one gem and then the one beside it, and the swap is made if the rules allow it. The module comes from a CDN, and `@0` is the major version.
|
|
322
|
+
|
|
323
|
+
```html
|
|
324
|
+
<!doctype html>
|
|
325
|
+
<meta charset="utf-8">
|
|
326
|
+
<title>Gem Swap</title>
|
|
327
|
+
<style>
|
|
328
|
+
#board { display: grid; grid-template-columns: repeat(8, 44px); gap: 4px; }
|
|
329
|
+
#board button { width: 44px; height: 44px; border-radius: 22px; border: 2px solid #333; }
|
|
330
|
+
#board button[aria-pressed="true"] { outline: 3px solid black; }
|
|
331
|
+
</style>
|
|
332
|
+
<p id="score" role="status"></p>
|
|
333
|
+
<div id="board"></div>
|
|
334
|
+
<script type="module">
|
|
335
|
+
import { advanceTicks, applyAction, createGame, legalActions } from "https://cdn.jsdelivr.net/npm/@johnmorrisdotca/houseki@0/dist/gem-swap.js";
|
|
336
|
+
|
|
337
|
+
const FILL = { red: "#c8372d", blue: "#2a5ea8", green: "#2f8a4f", gold: "#dfa11b", purple: "#7b4fa3", teal: "#1f9a9a" };
|
|
338
|
+
let game = createGame({ seed: "hello" });
|
|
339
|
+
let chosen = null;
|
|
340
|
+
|
|
341
|
+
function draw() {
|
|
342
|
+
document.getElementById("score").textContent = `Score ${game.score}, moves ${game.moves}`;
|
|
343
|
+
document.getElementById("board").replaceChildren(...game.board.map((gem, index) => {
|
|
344
|
+
const button = document.createElement("button");
|
|
345
|
+
button.style.background = FILL[gem.colour];
|
|
346
|
+
button.setAttribute("aria-label", gem.colour); // a colour is also a word
|
|
347
|
+
button.setAttribute("aria-pressed", String(chosen === index));
|
|
348
|
+
button.onclick = () => {
|
|
349
|
+
const swap = legalActions(game).find((action) => action.kind === "swap" && ((action.from === chosen && action.to === index) || (action.from === index && action.to === chosen)));
|
|
350
|
+
if (chosen !== null && swap) game = advanceTicks(applyAction(game, swap).state, 200).state; // time is a count of ticks
|
|
351
|
+
chosen = chosen === null || swap ? null : index;
|
|
352
|
+
draw();
|
|
353
|
+
};
|
|
354
|
+
return button;
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
draw();
|
|
358
|
+
</script>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### A challenge won by its recorded play
|
|
362
|
+
|
|
363
|
+
Every graded challenge ships a winning play, and the tests replay all 450. A server can do the same to check that a client really won, with no screen: start the level, make the recorded moves, and read the phase.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import { advanceTicks, applyAction, createLevel, levelManifest, statusOf } from "@johnmorrisdotca/houseki/falling-triplets";
|
|
367
|
+
|
|
368
|
+
const challenge = levelManifest[0]!;
|
|
369
|
+
console.log(challenge.number, challenge.title.en, "/", challenge.title.ja, `(${challenge.marks} of 5 marks)`);
|
|
370
|
+
|
|
371
|
+
let state = createLevel(1);
|
|
372
|
+
for (const move of challenge.witness) {
|
|
373
|
+
while (state.active!.x < move.x) state = applyAction(state, { kind: "right" }).state;
|
|
374
|
+
while (state.active!.x > move.x) state = applyAction(state, { kind: "left" }).state;
|
|
375
|
+
state = applyAction(state, { kind: "hard-drop" }).state;
|
|
376
|
+
state = advanceTicks(state, 200).state; // let it land and clear
|
|
377
|
+
}
|
|
378
|
+
console.log(statusOf(state));
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```text
|
|
382
|
+
1 Diagonal Thread 7 / 斜めの糸 7 (1 of 5 marks)
|
|
383
|
+
{ phase: 'won', score: 72, maxChain: 1 }
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### A bot that plays a free game
|
|
387
|
+
|
|
388
|
+
An engine lists what may be done now (`legalActions`), so a bot needs no knowledge of the rules. This one moves and cycles at random from a seeded stream and places every triplet, on the compact board, until the well is full.
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { advanceTicks, applyAction, createGame } from "@johnmorrisdotca/houseki/falling-triplets";
|
|
392
|
+
|
|
393
|
+
function stream(seed: number) { // a small seeded generator: the same game every run
|
|
394
|
+
return () => {
|
|
395
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
396
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
397
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
398
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const random = stream(5);
|
|
402
|
+
let game = createGame({ mode: "relaxed", seed: "bot", preset: "compact" });
|
|
403
|
+
let pieces = 0;
|
|
404
|
+
while (game.phase === "falling" && pieces < 300) {
|
|
405
|
+
for (let step = Math.floor(random() * 5); step > 0; step -= 1) game = applyAction(game, { kind: random() < 0.5 ? "left" : "right" }).state;
|
|
406
|
+
if (random() < 0.5) game = applyAction(game, { kind: "cycle-forward" }).state;
|
|
407
|
+
game = advanceTicks(applyAction(game, { kind: "place" }).state, 100).state;
|
|
408
|
+
pieces += 1;
|
|
409
|
+
}
|
|
410
|
+
console.log(pieces, "triplets;", game.phase, "with score", game.score);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```text
|
|
414
|
+
11 triplets; lost with score 30
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Gem Swap: moves, hints and tools
|
|
418
|
+
|
|
419
|
+
A swap is `{ kind: "swap", from, to }` by cell number, a hint is an action of its own, and a game that has used one is marked assisted. Stored tools are actions too.
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
import { advanceTicks, applyAction, createGame, hintGame, legalActions, statusOf } from "@johnmorrisdotca/houseki/gem-swap";
|
|
423
|
+
|
|
424
|
+
const game = createGame({ seed: "x" });
|
|
425
|
+
console.log(legalActions(game).length, "swaps; the first is", legalActions(game)[0]);
|
|
426
|
+
const moved = advanceTicks(applyAction(game, { kind: "swap", from: 0, to: 1 }).state, 200).state;
|
|
427
|
+
console.log("score", moved.score, "after", moved.moves, "move; phase", moved.phase);
|
|
428
|
+
|
|
429
|
+
const hinted = hintGame(game).state;
|
|
430
|
+
console.log("hint used, so the game is marked assisted:", statusOf(hinted).assisted);
|
|
431
|
+
|
|
432
|
+
const withTools = createGame({ seed: "x", tools: true });
|
|
433
|
+
console.log(withTools.inventory, legalActions(withTools).filter((action) => action.kind === "select-tool").length, "tools to choose");
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
```text
|
|
437
|
+
17 swaps; the first is { kind: 'swap', from: 0, to: 1 }
|
|
438
|
+
score 90 after 1 move; phase ready
|
|
439
|
+
hint used, so the game is marked assisted: true
|
|
440
|
+
{ bomb: 1, 'row-clear': 1, 'colour-clear': 1 } 3 tools to choose
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Stone Collapse: choose a group, look, then confirm
|
|
444
|
+
|
|
445
|
+
Stone Collapse is chosen by group: select a stone, read the score of the preview in `statusOf`, then `confirm` or `cancel`. Nothing is spent until it is confirmed.
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { applyAction, createGame, legalActions, statusOf } from "@johnmorrisdotca/houseki/stone-collapse";
|
|
449
|
+
|
|
450
|
+
let game = createGame({ seed: "x" });
|
|
451
|
+
const firstGroup = legalActions(game)[0]!; // select the first stone that has a group
|
|
452
|
+
console.log(firstGroup);
|
|
453
|
+
game = applyAction(game, firstGroup).state;
|
|
454
|
+
console.log("preview", statusOf(game).previewScore, "points; actions now:", legalActions(game).slice(-2));
|
|
455
|
+
const confirmed = applyAction(game, { kind: "confirm" });
|
|
456
|
+
console.log(confirmed.accepted, "score", statusOf(confirmed.state).score);
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
```text
|
|
460
|
+
{ kind: 'select', stoneId: 3 }
|
|
461
|
+
preview 10 points; actions now: [ { kind: 'confirm' }, { kind: 'cancel' } ]
|
|
462
|
+
true score 10
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Save a game and read it back
|
|
466
|
+
|
|
467
|
+
A game is its settings and the actions taken, so a save is that list as text, and reading it plays the actions again. A save that does not match its own record, or is not a save, is refused: `decodeGame` throws, so a server can catch it and say the save is bad.
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import { advanceTicks, applyAction, createGame, decodeGame, encodeGame } from "@johnmorrisdotca/houseki/falling-triplets";
|
|
471
|
+
|
|
472
|
+
let game = createGame({ mode: "relaxed", seed: "first-game" });
|
|
473
|
+
game = applyAction(game, { kind: "cycle-forward" }).state;
|
|
474
|
+
game = advanceTicks(applyAction(game, { kind: "place" }).state, 22).state;
|
|
475
|
+
|
|
476
|
+
const saved = encodeGame(game);
|
|
477
|
+
console.log(saved.length > 1000, decodeGame(saved)?.score === game.score);
|
|
478
|
+
for (const bad of ["not a save", saved.replace("first-game", "another")]) {
|
|
479
|
+
try {
|
|
480
|
+
decodeGame(bad);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.log("refused:", (error as Error).message);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
```text
|
|
488
|
+
true true
|
|
489
|
+
refused: Unexpected token 'o', "not a save" is not valid JSON
|
|
490
|
+
refused: Save checkpoint does not match its action recording
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Daily: the same board for everyone on a date
|
|
494
|
+
|
|
495
|
+
A Daily game takes the date from the host, in UTC, as its seed, and the queue of pieces comes from the date alone.
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
import { createGame } from "@johnmorrisdotca/houseki/falling-triplets";
|
|
499
|
+
|
|
500
|
+
const today = createGame({ mode: "daily", seed: "2026-10-06" });
|
|
501
|
+
const again = createGame({ mode: "daily", seed: "2026-10-06" });
|
|
502
|
+
const tomorrow = createGame({ mode: "daily", seed: "2026-10-07" });
|
|
503
|
+
console.log(JSON.stringify(today.next) === JSON.stringify(again.next), JSON.stringify(today.next) === JSON.stringify(tomorrow.next));
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
```text
|
|
507
|
+
true false
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### The three campaigns that came after
|
|
511
|
+
|
|
512
|
+
Magnetic Blocks, Shizen and Arashi each have fifty fixed puzzles ordered from easy to hard, with stable IDs, titles in both languages, an objective and a verified winning plan. A campaign has a finite queue and no falling clock.
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
import { createArashiLevel, createShizenLevel, arashiLevelManifest, shizenLevelManifest } from "@johnmorrisdotca/houseki/colour-chains";
|
|
516
|
+
import { createLevel, levelManifest } from "@johnmorrisdotca/houseki/magnetic-blocks";
|
|
517
|
+
|
|
518
|
+
console.log(levelManifest.length, shizenLevelManifest.length, arashiLevelManifest.length);
|
|
519
|
+
console.log(levelManifest[0]!.title.en, "|", shizenLevelManifest[0]!.title.ja, "|", arashiLevelManifest[0]!.title.en);
|
|
520
|
+
console.log(createLevel(1).phase, createShizenLevel(1).settings.goal, createArashiLevel(1).settings.weather);
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
```text
|
|
524
|
+
50 50 50
|
|
525
|
+
Bonded landing 25 | 磁力の後にリバウンド | Save a path for lightning
|
|
526
|
+
falling { kind: 'clear-targets', targetIds: [ 1, 2, 3 ] } frequent
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### A page that plays a level with the keyboard
|
|
530
|
+
|
|
531
|
+
The demo's players are the worked example of drawing the states: `demo/chains.js`, `demo/blocks.js`, `demo/tools.js` and `demo/demo.js` are plain DOM, with the keyboard and keypad scheduled through `createInputScheduler`, which turns held keys into actions on a count of ticks.
|
|
532
|
+
|
|
533
|
+
```ts no-run
|
|
534
|
+
import { actionsForTick, advanceTicks, applyAction, createInputScheduler, createLevel, queueInputEdge } from "@johnmorrisdotca/houseki/falling-triplets";
|
|
535
|
+
|
|
536
|
+
let game = createLevel(1);
|
|
537
|
+
let input = createInputScheduler();
|
|
538
|
+
addEventListener("keydown", (event) => {
|
|
539
|
+
if (event.key === "ArrowLeft") input = queueInputEdge(input, { kind: "left" });
|
|
540
|
+
if (event.key === "ArrowRight") input = queueInputEdge(input, { kind: "right" });
|
|
541
|
+
if (event.key === " ") input = queueInputEdge(input, { kind: "hard-drop" });
|
|
542
|
+
});
|
|
543
|
+
setInterval(() => { // the page owns the clock; the engine counts ticks
|
|
544
|
+
const [actions, next] = actionsForTick(input);
|
|
545
|
+
input = next;
|
|
546
|
+
for (const action of actions) game = applyAction(game, action).state;
|
|
547
|
+
game = advanceTicks(game, 1).state;
|
|
548
|
+
}, 50);
|
|
549
|
+
```
|
|
550
|
+
|
|
125
551
|
## API
|
|
126
552
|
|
|
127
553
|
Every export of every entry point is in the [API reference](https://johnmorrisdotca.github.io/houseki/api.html), made from the source when the demo is built, with each signature and doc comment. A test fails when a public export has no doc comment.
|
|
128
554
|
|
|
555
|
+
### Entry points
|
|
556
|
+
|
|
557
|
+
| Import | What it holds |
|
|
558
|
+
| --- | --- |
|
|
559
|
+
| `@johnmorrisdotca/houseki` | Every engine, grouped by game as `fallingTriplets`, `stoneCollapse`, `colourChains`, `gemSwap`, `nature` and `magneticBlocks` |
|
|
560
|
+
| `@johnmorrisdotca/houseki/falling-triplets` | Falling Triplets: 100 challenges, three lessons, saves |
|
|
561
|
+
| `@johnmorrisdotca/houseki/colour-chains` | Colour Chains: 50 challenges, three lessons, and the Shizen and Arashi campaigns |
|
|
562
|
+
| `@johnmorrisdotca/houseki/stone-collapse` | Stone Collapse: 100 challenges, three lessons, hints, undo and stored tools |
|
|
563
|
+
| `@johnmorrisdotca/houseki/gem-swap` | Gem Swap: 50 challenges, three lessons, hints, undo, stored tools and the Black Hole |
|
|
564
|
+
| `@johnmorrisdotca/houseki/magnetic-blocks` | Magnetic Blocks: bonded squares, the magnetic floor and its 50 challenges |
|
|
565
|
+
| `@johnmorrisdotca/houseki/nature` | The deterministic attraction, rebound and environment utilities |
|
|
566
|
+
|
|
567
|
+
### The calls to learn first
|
|
568
|
+
|
|
569
|
+
| Call | What it does |
|
|
570
|
+
| --- | --- |
|
|
571
|
+
| `createGame(options)` and `createLevel(number)` | A free game from a seed, and a graded challenge |
|
|
572
|
+
| `applyAction(state, action)` | `{ state, events, accepted }`: the new state, what happened, and whether the action was allowed |
|
|
573
|
+
| `advanceTicks(state, n)` | Time passing, as a count of ticks and never the clock |
|
|
574
|
+
| `legalActions(state)` | Every action that may be taken now |
|
|
575
|
+
| `encodeGame(state)` and `decodeGame(text)` | A save as text, and back; a save that does not check is refused with an error |
|
|
576
|
+
| `statusOf(state)` | The phase, score and what a screen needs to say |
|
|
577
|
+
|
|
129
578
|
## Theming
|
|
130
579
|
|
|
131
580
|
The engines draw nothing. The demo's players take their colours from the family's stylesheet, `demo/family.css`: custom properties for the page, ink, accent and the felt the board sits on, which follow `prefers-color-scheme`, and five table cloths (green, blue, red, black and wood) chosen in the header. The boards add their own `--board`, `--frame` and `--gem-edge` in `demo/houseki.css`, and a Board material choice (ivory, slate or wood). Gems are told apart by their symbol as well as their colour, so no choice of cloth hides a colour.
|
|
@@ -142,6 +591,18 @@ The engines draw nothing. The demo's players take their colours from the family'
|
|
|
142
591
|
| Saves | a replay of the actions taken, checked when it is decoded; a save that does not match its own record is refused |
|
|
143
592
|
| Tools | capped inventory, earned by ordinary clears, and not offered in Daily or in authored challenges |
|
|
144
593
|
|
|
594
|
+
## Accessibility
|
|
595
|
+
|
|
596
|
+
The engines draw nothing, so what they can do is give a player the facts it needs; what a person hears and presses is the player's. The demo's players are the worked example, and what follows is what they do and what they do not yet.
|
|
597
|
+
|
|
598
|
+
- **Colour is never alone.** Every colour has its own permanent symbol on the gem (circle, square, triangle, diamond, cross and a sixth), kept under selection and flashes, so no choice of cloth or board material hides a colour. Special gems add their marks around the symbol and do not replace it.
|
|
599
|
+
- **The keyboard and the keypad play every game.** Falling games: ← → move, ↑ rotate, Z and X reverse and cycle, ↓ soft drop, Space place or drop, Escape pause, and the keypad's 4, 6, 7, 9, 5 and 2 for the same. The full-board games are a `grid` with one tab stop: Tab reaches it, the arrow keys move between gems, and Enter or Space chooses one. Every on-screen button is also at least 44 pixels high.
|
|
600
|
+
- **A status line that is spoken.** What to do next and what happened ("Place the triplet", "Choose a gem or a tool.", the result of a challenge) is in an `aria-live="polite"` line, and the goal's progress is a labelled `progress` element with its minimum, maximum and value, so a screen reader hears meaningful thresholds and the result and not every animation frame.
|
|
601
|
+
- **Reduced motion is respected.** An Animation setting is on by default, a Reduce motion choice turns every movement off, and the system's `prefers-reduced-motion` stills them even when Animation is on: the final cells are drawn at once and nothing about timing or the rules changes.
|
|
602
|
+
- **Nothing is timed against you in Relaxed play**, and a challenge has no falling clock.
|
|
603
|
+
- **Light and dark** follow the device, and every word of the demo is in English and Japanese.
|
|
604
|
+
- **Not yet.** In the falling games the well is drawn as one image named "Gem board", so a screen reader does not read out which gem is where; the full-board games' grid does name each cell. The colour pairs have not been measured against WCAG contrast ratios, and the design pack asks for that to be done. The Japanese has not been read by a native reader (see [Languages](#languages)).
|
|
605
|
+
|
|
145
606
|
## Browser support
|
|
146
607
|
|
|
147
608
|
The engines run wherever JavaScript does: Node 22 or later, and current Chrome, Edge, Firefox and Safari. The demo's tests run in Chromium and WebKit, at a phone's width and a desk's.
|
|
@@ -236,6 +697,10 @@ Houseki (宝石) is Japanese for a gem or precious stone: the pieces of these ga
|
|
|
236
697
|
|
|
237
698
|
Match games are an old family and their rules are common property. These five are original: the rules are written out in the design pack in our own words, every board is generated and checked by the repository's own scripts, and the pictures are drawn in code. No art, sound or level comes from another game.
|
|
238
699
|
|
|
700
|
+
### Used by
|
|
701
|
+
|
|
702
|
+
Using Houseki in something? Open an *Add my project* issue and we will add you.
|
|
703
|
+
|
|
239
704
|
### The family
|
|
240
705
|
|
|
241
706
|
<!-- family:start (made by scripts/family-readme.mjs from scripts/family-template.mjs; change those, not this) -->
|
|
@@ -274,10 +739,12 @@ Houseki is one of twenty-four packages, each made for the same site, each at
|
|
|
274
739
|
|
|
275
740
|
```sh
|
|
276
741
|
pnpm install
|
|
277
|
-
pnpm check
|
|
278
|
-
pnpm test:package
|
|
279
|
-
pnpm test:demo
|
|
280
|
-
pnpm site
|
|
742
|
+
pnpm check # lint, types and tests: every game's rules, every campaign's recorded wins
|
|
743
|
+
pnpm test:package # pack it as npm does, install it in an empty project, import every entry
|
|
744
|
+
pnpm test:demo # build the demo and play it in a real browser, at a phone's width and a desk's
|
|
745
|
+
pnpm site # build the demo and the API reference into site/
|
|
746
|
+
pnpm test:readme # run every example in this README against the built package
|
|
747
|
+
pnpm screenshots:readme # take the README's pictures from the built demo, in light and dark
|
|
281
748
|
```
|
|
282
749
|
|
|
283
750
|
## Contributing
|
|
@@ -286,8 +753,9 @@ Ideas, bug reports and pull requests are welcome in the [issues](https://github.
|
|
|
286
753
|
|
|
287
754
|
## Changes
|
|
288
755
|
|
|
289
|
-
Every release is written up in [CHANGELOG.md](./CHANGELOG.md).
|
|
756
|
+
Every release is written up in [CHANGELOG.md](./CHANGELOG.md). The latest release, 0.2.1, adds no code: it is this README in full, with pictures of every game, examples that are run on every change, examples for React, Vue, Svelte and Angular, and an Accessibility section.
|
|
290
757
|
|
|
291
758
|
## Licence
|
|
292
759
|
|
|
293
760
|
[MIT](./LICENSE) © John Morris. The code, the boards and the words are all the package's own. Report vulnerabilities through [SECURITY.md](./SECURITY.md).
|
|
761
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/houseki",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Houseki 宝石: five original gem and stone puzzle engines, 450 graded challenges, bilingual lessons, deterministic saves, magnetic floors, Shizen and Arashi campaigns. TypeScript. Zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"puzzle",
|
|
@@ -109,6 +109,8 @@
|
|
|
109
109
|
"prepublishOnly": "pnpm check && pnpm build",
|
|
110
110
|
"site": "pnpm build && node scripts/site.mjs",
|
|
111
111
|
"test:demo": "pnpm site && playwright test && node --test --test-concurrency=2 e2e/review/appearance-api.test.mjs e2e/review/endings.test.mjs e2e/review/full-board-recovery.test.mjs e2e/review/nature-previews.test.mjs e2e/review/responsive.test.mjs",
|
|
112
|
+
"screenshots:readme": "pnpm site && node scripts/readme-pictures.mjs",
|
|
113
|
+
"test:readme": "pnpm build && node scripts/check-readme-examples.mjs",
|
|
112
114
|
"test:review": "pnpm site && node --test --test-concurrency=2 e2e/review/appearance-api.test.mjs e2e/review/endings.test.mjs e2e/review/full-board-recovery.test.mjs e2e/review/nature-previews.test.mjs e2e/review/responsive.test.mjs"
|
|
113
115
|
},
|
|
114
116
|
"devDependencies": {
|