@johnmorrisdotca/kumimoji 1.0.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 +53 -0
- package/CONTRIBUTING.md +38 -0
- package/LICENSE +21 -0
- package/NOTICE.md +38 -0
- package/README.md +233 -0
- package/dist/check.d.ts +19 -0
- package/dist/check.js +70 -0
- package/dist/computer.types.d.ts +23 -0
- package/dist/computer.types.js +1 -0
- package/dist/computerPlay.d.ts +17 -0
- package/dist/computerPlay.js +203 -0
- package/dist/computerTurn.d.ts +31 -0
- package/dist/computerTurn.js +94 -0
- package/dist/generate.d.ts +29 -0
- package/dist/generate.js +278 -0
- package/dist/grid.d.ts +97 -0
- package/dist/grid.js +208 -0
- package/dist/help.d.ts +20 -0
- package/dist/help.js +57 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +44 -0
- package/dist/judge.d.ts +13 -0
- package/dist/judge.js +19 -0
- package/dist/kana.d.ts +25 -0
- package/dist/kana.js +38 -0
- package/dist/kumimoji.types.d.ts +49 -0
- package/dist/kumimoji.types.js +1 -0
- package/dist/party.d.ts +91 -0
- package/dist/party.js +172 -0
- package/dist/party.types.d.ts +86 -0
- package/dist/party.types.js +1 -0
- package/dist/partyKept.d.ts +17 -0
- package/dist/partyKept.js +149 -0
- package/dist/partySeats.d.ts +53 -0
- package/dist/partySeats.js +119 -0
- package/dist/partyTurns.d.ts +75 -0
- package/dist/partyTurns.js +153 -0
- package/dist/placement.d.ts +26 -0
- package/dist/placement.js +87 -0
- package/dist/play.d.ts +76 -0
- package/dist/play.js +165 -0
- package/dist/random.d.ts +11 -0
- package/dist/random.js +25 -0
- package/dist/react.d.ts +32 -0
- package/dist/react.js +53 -0
- package/dist/showcase.d.ts +51 -0
- package/dist/showcase.js +59 -0
- package/dist/tableView.d.ts +92 -0
- package/dist/tableView.js +127 -0
- package/dist/tileFace.d.ts +25 -0
- package/dist/tileFace.js +39 -0
- package/dist/tileFamily.d.ts +11 -0
- package/dist/tileFamily.js +23 -0
- package/dist/tileWords.d.ts +70 -0
- package/dist/tileWords.js +164 -0
- package/dist/tileWordsModule.d.ts +4 -0
- package/dist/tileWordsModule.js +13 -0
- package/dist/tiles.constants.d.ts +108 -0
- package/dist/tiles.constants.js +132 -0
- package/dist/turn.d.ts +62 -0
- package/dist/turn.js +96 -0
- package/dist/ui/board.d.ts +29 -0
- package/dist/ui/board.js +37 -0
- package/dist/ui/mount.d.ts +36 -0
- package/dist/ui/mount.js +257 -0
- package/dist/ui/style.d.ts +6 -0
- package/dist/ui/style.js +40 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/wilds.d.ts +27 -0
- package/dist/wilds.js +111 -0
- package/dist/words.d.ts +6 -0
- package/dist/words.en.data.d.ts +23 -0
- package/dist/words.en.data.js +610 -0
- package/dist/words.ja.data.d.ts +29 -0
- package/dist/words.ja.data.js +29 -0
- package/dist/words.js +6 -0
- package/package.json +90 -0
package/dist/play.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { decodeGrid, encodeGrid, lettersOf, sameLetters } from "./grid.js";
|
|
2
|
+
import { tileFace } from "./tileFace.js";
|
|
3
|
+
import { KUMIMOJI_DRAW, KUMIMOJI_TRADE } from "./tiles.constants.js";
|
|
4
|
+
import { tileWords } from "./tileWords.js";
|
|
5
|
+
/** The opening hand: the first `handSize` tiles of the bag, and an empty table. */
|
|
6
|
+
export function deal(bag, handSize) {
|
|
7
|
+
const taken = Math.min(handSize, bag.length);
|
|
8
|
+
return { bag, returned: "", taken, tiles: new Map(), hand: [...bag.slice(0, taken)] };
|
|
9
|
+
}
|
|
10
|
+
/** How many tiles are still in the bag. */
|
|
11
|
+
export function tilesLeft(play) {
|
|
12
|
+
return play.bag.length + play.returned.length - play.taken;
|
|
13
|
+
}
|
|
14
|
+
function withTiles(play, change) {
|
|
15
|
+
const tiles = new Map(play.tiles);
|
|
16
|
+
change(tiles);
|
|
17
|
+
return tiles;
|
|
18
|
+
}
|
|
19
|
+
/** A tile from the hand onto an empty square. */
|
|
20
|
+
export function placeFromHand(play, handAt, square) {
|
|
21
|
+
const letter = play.hand[handAt];
|
|
22
|
+
if (letter === undefined || play.tiles.has(square))
|
|
23
|
+
return play;
|
|
24
|
+
return { ...play, tiles: withTiles(play, (tiles) => tiles.set(square, letter)), hand: play.hand.filter((_, at) => at !== handAt) };
|
|
25
|
+
}
|
|
26
|
+
/** Give a hand tile its chosen reading, without changing which physical tile it is. */
|
|
27
|
+
export function assignHandTile(play, handAt, face) {
|
|
28
|
+
if (play.hand[handAt] === undefined || face.length !== 1)
|
|
29
|
+
return play;
|
|
30
|
+
const hand = [...play.hand];
|
|
31
|
+
hand[handAt] = face;
|
|
32
|
+
return { ...play, hand };
|
|
33
|
+
}
|
|
34
|
+
/** Give a tile on the table its chosen reading, without changing its square. */
|
|
35
|
+
export function assignTableTile(play, square, face) {
|
|
36
|
+
if (!play.tiles.has(square) || face.length !== 1)
|
|
37
|
+
return play;
|
|
38
|
+
return { ...play, tiles: withTiles(play, (tiles) => tiles.set(square, face)) };
|
|
39
|
+
}
|
|
40
|
+
/** A tile on the table to another square: to an empty one it moves, onto a tile the two change places. */
|
|
41
|
+
export function moveOnTable(play, from, to) {
|
|
42
|
+
const moving = play.tiles.get(from);
|
|
43
|
+
if (from === to || moving === undefined)
|
|
44
|
+
return play;
|
|
45
|
+
const there = play.tiles.get(to);
|
|
46
|
+
return {
|
|
47
|
+
...play,
|
|
48
|
+
tiles: withTiles(play, (tiles) => {
|
|
49
|
+
tiles.delete(from);
|
|
50
|
+
if (there !== undefined)
|
|
51
|
+
tiles.set(from, there);
|
|
52
|
+
tiles.set(to, moving);
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** A tile from the hand onto a square that holds one: they change places, the table's tile going to the hand where the other was. */
|
|
57
|
+
export function swapWithHand(play, handAt, square) {
|
|
58
|
+
const letter = play.hand[handAt];
|
|
59
|
+
const lifted = play.tiles.get(square);
|
|
60
|
+
if (letter === undefined || lifted === undefined)
|
|
61
|
+
return play;
|
|
62
|
+
const hand = [...play.hand];
|
|
63
|
+
hand[handAt] = lifted;
|
|
64
|
+
return { ...play, tiles: withTiles(play, (tiles) => tiles.set(square, letter)), hand };
|
|
65
|
+
}
|
|
66
|
+
/** A tile on the table back to the end of the hand. */
|
|
67
|
+
export function liftToHand(play, square) {
|
|
68
|
+
const letter = play.tiles.get(square);
|
|
69
|
+
if (letter === undefined)
|
|
70
|
+
return play;
|
|
71
|
+
return { ...play, tiles: withTiles(play, (tiles) => tiles.delete(square)), hand: [...play.hand, letter] };
|
|
72
|
+
}
|
|
73
|
+
/** Every tile on the table back to the hand. */
|
|
74
|
+
export function liftAll(play) {
|
|
75
|
+
return { ...play, tiles: new Map(), hand: [...play.hand, ...play.tiles.values()] };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The hand in order (John, 2026-09-28: "it gets sorted for letters
|
|
79
|
+
* alphabetically"): English A to Z, Japanese あいうえお, which is the order
|
|
80
|
+
* of the kana's tile codes, and the wilds last as they came.
|
|
81
|
+
*/
|
|
82
|
+
export function sortHand(play) {
|
|
83
|
+
const hand = [...play.hand].sort((a, b) => Number(tileFace(a).wild) - Number(tileFace(b).wild) || (tileFace(a).wild ? 0 : a < b ? -1 : a > b ? 1 : 0));
|
|
84
|
+
return { ...play, hand };
|
|
85
|
+
}
|
|
86
|
+
/** Whether Draw may be pressed: the hand used, the grid sound, and a tile left to draw. */
|
|
87
|
+
export function mayDraw(play, verdict) {
|
|
88
|
+
return play.hand.length === 0 && verdict.sound && tilesLeft(play) > 0;
|
|
89
|
+
}
|
|
90
|
+
/** The next tile or tiles out of the bag into the hand. */
|
|
91
|
+
export function draw(play, count = KUMIMOJI_DRAW) {
|
|
92
|
+
const coming = (play.bag + play.returned).slice(play.taken, play.taken + count);
|
|
93
|
+
if (coming.length === 0)
|
|
94
|
+
return play;
|
|
95
|
+
return { ...play, taken: play.taken + coming.length, hand: [...play.hand, ...coming] };
|
|
96
|
+
}
|
|
97
|
+
/** Whether a trade may be made: the bag holds as many as a trade takes. */
|
|
98
|
+
export function mayTrade(play) {
|
|
99
|
+
return tilesLeft(play) >= KUMIMOJI_TRADE.take;
|
|
100
|
+
}
|
|
101
|
+
/** One tile from the hand to the bottom of the bag, and the next three out of it. */
|
|
102
|
+
export function trade(play, handAt) {
|
|
103
|
+
const letter = play.hand[handAt];
|
|
104
|
+
if (letter === undefined || !mayTrade(play))
|
|
105
|
+
return play;
|
|
106
|
+
const returned = play.returned + letter;
|
|
107
|
+
const coming = (play.bag + returned).slice(play.taken, play.taken + KUMIMOJI_TRADE.take);
|
|
108
|
+
return { ...play, returned, taken: play.taken + coming.length, hand: [...play.hand.filter((_, at) => at !== handAt), ...coming] };
|
|
109
|
+
}
|
|
110
|
+
/** Whether the game is over: the bag empty, the hand used, and the grid sound. */
|
|
111
|
+
export function isFinished(play, verdict) {
|
|
112
|
+
return play.hand.length === 0 && tilesLeft(play) === 0 && verdict.sound;
|
|
113
|
+
}
|
|
114
|
+
/*
|
|
115
|
+
* A GAME KEPT half way, as one string (`PuzzleRun.progress`): how many tiles
|
|
116
|
+
* were taken, the tiles traded back, the hand in its order and the grid
|
|
117
|
+
* (`encodeGrid`), as `12:q:ae:cat/2o/2w`. The bag is the puzzle's own, from
|
|
118
|
+
* its seed, so it is not written again, and where on the table the grid stood
|
|
119
|
+
* is the view's and not kept.
|
|
120
|
+
*/
|
|
121
|
+
export function encodeTileProgress(play) {
|
|
122
|
+
return `${play.taken}:${play.returned}:${play.hand.join("")}:${encodeGrid(play.tiles)}`;
|
|
123
|
+
}
|
|
124
|
+
/** The parts of a kept game, or null for a string that is not one: its shape only, with no bag to check it against. */
|
|
125
|
+
export function readTileProgress(code) {
|
|
126
|
+
const parts = code.split(":");
|
|
127
|
+
if (parts.length !== 4)
|
|
128
|
+
return null;
|
|
129
|
+
const [takenText, returned, hand, grid] = parts;
|
|
130
|
+
if (!/^\d{1,3}$/.test(takenText) || !/^[a-zA-Z*\uE000-\uF8FF]*$/.test(returned) || !/^[a-zA-Z*\uE000-\uF8FF]*$/.test(hand))
|
|
131
|
+
return null;
|
|
132
|
+
const tiles = decodeGrid(grid);
|
|
133
|
+
if (tiles === null)
|
|
134
|
+
return null;
|
|
135
|
+
return { taken: Number(takenText), returned, hand: [...hand], tiles };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A kept game opened again on its own bag, or null where it does not belong
|
|
139
|
+
* to it: every tile held — hand and table — must be one taken from the bag
|
|
140
|
+
* and not given back. Null opens the game fresh rather than on a grid that
|
|
141
|
+
* never came out of this bag.
|
|
142
|
+
*/
|
|
143
|
+
export function decodeTileProgress(code, bag, language = "english") {
|
|
144
|
+
const read = readTileProgress(code);
|
|
145
|
+
if (read === null)
|
|
146
|
+
return null;
|
|
147
|
+
const words = tileWords(language);
|
|
148
|
+
const line = bag + read.returned;
|
|
149
|
+
if (read.taken > line.length)
|
|
150
|
+
return null;
|
|
151
|
+
const held = lettersOf([...line.slice(0, read.taken)].map(words.familyKey).filter((tile) => tile !== null));
|
|
152
|
+
for (const tile of read.returned) {
|
|
153
|
+
const identity = words.familyKey(tile);
|
|
154
|
+
if (identity === null)
|
|
155
|
+
return null;
|
|
156
|
+
held.set(identity, (held.get(identity) ?? 0) - 1);
|
|
157
|
+
}
|
|
158
|
+
for (const [tile, count] of held)
|
|
159
|
+
if (count === 0)
|
|
160
|
+
held.delete(tile);
|
|
161
|
+
const played = [...read.hand, ...read.tiles.values()].map(words.familyKey);
|
|
162
|
+
if (played.some((tile) => tile === null) || !sameLetters(held, lettersOf(played)))
|
|
163
|
+
return null;
|
|
164
|
+
return { bag, returned: read.returned, taken: read.taken, tiles: read.tiles, hand: read.hand };
|
|
165
|
+
}
|
package/dist/random.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A seeded random, so that one seed deals one game in every browser: two
|
|
3
|
+
* players racing the same bag, or one game opened again tomorrow. mulberry32
|
|
4
|
+
* is small, fast and good enough for shuffling tiles; nothing here is a
|
|
5
|
+
* credential.
|
|
6
|
+
*/
|
|
7
|
+
/** A number in [0, 1), like `Math.random`, from a stream a seed fixes. */
|
|
8
|
+
export type Random = () => number;
|
|
9
|
+
export declare function seededRandom(seed: number): Random;
|
|
10
|
+
/** A copy of the list in a random order (Fisher–Yates). */
|
|
11
|
+
export declare function shuffled<T>(items: readonly T[], random: Random): T[];
|
package/dist/random.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A seeded random, so that one seed deals one game in every browser: two
|
|
3
|
+
* players racing the same bag, or one game opened again tomorrow. mulberry32
|
|
4
|
+
* is small, fast and good enough for shuffling tiles; nothing here is a
|
|
5
|
+
* credential.
|
|
6
|
+
*/
|
|
7
|
+
export function seededRandom(seed) {
|
|
8
|
+
let state = seed >>> 0;
|
|
9
|
+
return () => {
|
|
10
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
11
|
+
let t = state;
|
|
12
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
13
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
14
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** A copy of the list in a random order (Fisher–Yates). */
|
|
18
|
+
export function shuffled(items, random) {
|
|
19
|
+
const out = [...items];
|
|
20
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
21
|
+
const j = Math.floor(random() * (i + 1));
|
|
22
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
import type { GridVerdict, Tiles } from "./grid.ts";
|
|
3
|
+
import { type KumimojiTableOptions } from "./ui/mount.ts";
|
|
4
|
+
export type KumimojiBoardProps = {
|
|
5
|
+
tiles: Tiles;
|
|
6
|
+
/** From `judgeWithWords`: marks the runs that are not words and the tiles apart. Without it, no marks. */
|
|
7
|
+
verdict?: GridVerdict | null;
|
|
8
|
+
/** What to print on a tile: `words.glyphOf` for the language played. */
|
|
9
|
+
glyphOf?: (tile: string) => string;
|
|
10
|
+
/** A square pressed, empty or not. Without it the board is only a picture. */
|
|
11
|
+
onSquare?: (square: string) => void;
|
|
12
|
+
/** A square to ring, such as the one holding a tile picked up. */
|
|
13
|
+
held?: string | null;
|
|
14
|
+
/** The side of a square in pixels. */
|
|
15
|
+
square?: number;
|
|
16
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, "onClick">;
|
|
17
|
+
/**
|
|
18
|
+
* A KUMIMOJI TABLE, AS A REACT COMPONENT: the tiles of a grid with a margin
|
|
19
|
+
* of empty squares round them, a run that is not a word ringed red and a tile
|
|
20
|
+
* apart from the crossword faded, and a press on any square reported by its
|
|
21
|
+
* key (`squareAt(row, col)`). It draws; what a press does is yours to decide
|
|
22
|
+
* with `placeFromHand`, `moveOnTable` and the rest.
|
|
23
|
+
*/
|
|
24
|
+
export declare function KumimojiBoard({ tiles, verdict, glyphOf, onSquare, held, square, style, ...element }: KumimojiBoardProps): import("react").JSX.Element;
|
|
25
|
+
export type KumimojiTableProps = KumimojiTableOptions & Omit<HTMLAttributes<HTMLDivElement>, keyof KumimojiTableOptions>;
|
|
26
|
+
/**
|
|
27
|
+
* A whole game alone, as a React component: the plain-DOM table
|
|
28
|
+
* (`mountKumimoji`) mounted into this component's element once the browser
|
|
29
|
+
* has it. Options are read when it mounts; give it a new `key` to start over
|
|
30
|
+
* with different ones.
|
|
31
|
+
*/
|
|
32
|
+
export declare function KumimojiTable({ language, hand, level, gameLength, diagonals, seed, onFinish, ...element }: KumimojiTableProps): import("react").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { boardModel } from "./ui/board.js";
|
|
4
|
+
import { mountKumimoji } from "./ui/mount.js";
|
|
5
|
+
const TILE = {
|
|
6
|
+
display: "grid",
|
|
7
|
+
placeItems: "center",
|
|
8
|
+
borderRadius: 6,
|
|
9
|
+
background: "#f3e6c8",
|
|
10
|
+
color: "#2a2118",
|
|
11
|
+
fontWeight: 700,
|
|
12
|
+
boxShadow: "0 2px 0 rgba(0,0,0,.35)",
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A KUMIMOJI TABLE, AS A REACT COMPONENT: the tiles of a grid with a margin
|
|
16
|
+
* of empty squares round them, a run that is not a word ringed red and a tile
|
|
17
|
+
* apart from the crossword faded, and a press on any square reported by its
|
|
18
|
+
* key (`squareAt(row, col)`). It draws; what a press does is yours to decide
|
|
19
|
+
* with `placeFromHand`, `moveOnTable` and the rest.
|
|
20
|
+
*/
|
|
21
|
+
export function KumimojiBoard({ tiles, verdict = null, glyphOf, onSquare, held = null, square = 36, style, ...element }) {
|
|
22
|
+
const model = boardModel(tiles, verdict, glyphOf);
|
|
23
|
+
return (_jsx("div", { role: "grid", style: { display: "grid", gap: 2, gridTemplateColumns: `repeat(${model.cols}, ${square}px)`, width: "max-content", ...style }, ...element, children: model.squares.map((cell) => {
|
|
24
|
+
const ring = cell.square === held ? "0 0 0 3px #ffcf3f" : cell.mark === "misspelt" ? "0 0 0 3px #d9534f inset" : undefined;
|
|
25
|
+
const look = cell.tile === null
|
|
26
|
+
? { width: square, height: square, borderRadius: 5, background: "rgba(127,127,127,.18)", boxShadow: ring }
|
|
27
|
+
: { ...TILE, width: square, height: square, fontSize: square * 0.55, background: cell.wild ? "#f6d27a" : TILE.background, opacity: cell.mark === "apart" ? 0.6 : 1, boxShadow: ring ?? TILE.boxShadow };
|
|
28
|
+
return (_jsx("button", { type: "button", "data-square": cell.square, "aria-label": cell.tile === null ? "Empty square" : cell.glyph, onClick: onSquare === undefined ? undefined : () => onSquare(cell.square), style: { border: 0, padding: 0, cursor: onSquare === undefined ? "default" : "pointer", ...look }, children: cell.glyph.toUpperCase() }, cell.square));
|
|
29
|
+
}) }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A whole game alone, as a React component: the plain-DOM table
|
|
33
|
+
* (`mountKumimoji`) mounted into this component's element once the browser
|
|
34
|
+
* has it. Options are read when it mounts; give it a new `key` to start over
|
|
35
|
+
* with different ones.
|
|
36
|
+
*/
|
|
37
|
+
export function KumimojiTable({ language, hand, level, gameLength, diagonals, seed, onFinish, ...element }) {
|
|
38
|
+
const host = useRef(null);
|
|
39
|
+
const latest = useRef(onFinish);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
latest.current = onFinish;
|
|
42
|
+
});
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const target = host.current;
|
|
45
|
+
if (target === null)
|
|
46
|
+
return;
|
|
47
|
+
const table = mountKumimoji(target, { language, hand, level, gameLength, diagonals, seed, onFinish: (result) => latest.current?.(result) });
|
|
48
|
+
return () => table.destroy();
|
|
49
|
+
// Mounted once per key, as documented above.
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
}, []);
|
|
52
|
+
return _jsx("div", { ref: host, ...element });
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { KumimojiLanguage, KumimojiLength } from "./kumimoji.types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* WHAT THE KUMIMOJI PAGES SHOW OF THE GAME, worked out from the tables the
|
|
4
|
+
* game is played by and never typed a second time: the two tile sets with
|
|
5
|
+
* their counts, every form a kana tile plays as, and how many tiles and wilds
|
|
6
|
+
* each length of game deals. Pure, so the rules page draws it at build time
|
|
7
|
+
* and the tests hold it to the constants.
|
|
8
|
+
*/
|
|
9
|
+
/** One kind of tile in a set: the code the game deals, what is printed on it, what shows in its corner, and how many the set holds. */
|
|
10
|
+
export type MixTile = {
|
|
11
|
+
code: string;
|
|
12
|
+
glyph: string;
|
|
13
|
+
forms: string;
|
|
14
|
+
count: number;
|
|
15
|
+
};
|
|
16
|
+
/** A whole set, in its own order (a to z, あ to ん), with what a reader needs to see how uneven it is. */
|
|
17
|
+
export type MixShown = {
|
|
18
|
+
tiles: MixTile[];
|
|
19
|
+
total: number;
|
|
20
|
+
/** The most of any one tile, which the bars are drawn against. */
|
|
21
|
+
most: number;
|
|
22
|
+
/** The fewest of any one tile, and the tiles with only that many: the hard ones. */
|
|
23
|
+
fewest: number;
|
|
24
|
+
rarest: string[];
|
|
25
|
+
/** The tiles with the most, the ones a hand is full of. */
|
|
26
|
+
commonest: string[];
|
|
27
|
+
};
|
|
28
|
+
export declare function mixShown(language: KumimojiLanguage): MixShown;
|
|
29
|
+
export declare function formsOfTile(kana: string): string[];
|
|
30
|
+
/** One length of game at one opening hand: how many tiles it deals from one set and from two, and how many of them are wild at each level. */
|
|
31
|
+
export type LengthRow = {
|
|
32
|
+
length: KumimojiLength;
|
|
33
|
+
tiles: number;
|
|
34
|
+
/** With the Double set, English only. */
|
|
35
|
+
doubleTiles: number;
|
|
36
|
+
wilds: Record<"easy" | "medium" | "hard", number>;
|
|
37
|
+
};
|
|
38
|
+
export declare const LENGTHS: readonly KumimojiLength[];
|
|
39
|
+
export declare function lengthRows(hand?: number): LengthRow[];
|
|
40
|
+
/**
|
|
41
|
+
* THE TRY-IT ON THE FRONT DOOR: a Kumimoji of ten tiles, played in the
|
|
42
|
+
* reader's browser with the game's own moves and the game's own word check.
|
|
43
|
+
* A hand of seven, then three to draw, the last of them a wild. The bag is
|
|
44
|
+
* fixed rather than dealt, so the page is the same for everybody and needs no
|
|
45
|
+
* generator; `showcase.test.ts` proves it can be finished, with a crossword
|
|
46
|
+
* checked against the real list.
|
|
47
|
+
*/
|
|
48
|
+
export declare const TRY_IT: {
|
|
49
|
+
readonly bag: "rotenasdi*";
|
|
50
|
+
readonly hand: 7;
|
|
51
|
+
};
|
package/dist/showcase.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BASE_KANA, CORNER_FORMS, tileKana } from "./kana.js";
|
|
2
|
+
import { kanaTileCode } from "./tileFace.js";
|
|
3
|
+
import { JAPANESE_TILE_MIX, KUMIMOJI_HANDS, TILE_MIX, kumimojiTileCount, kumimojiWildCount } from "./tiles.constants.js";
|
|
4
|
+
export function mixShown(language) {
|
|
5
|
+
const tiles = language === "english"
|
|
6
|
+
? Object.entries(TILE_MIX).map(([letter, count]) => ({ code: letter, glyph: letter, forms: "", count }))
|
|
7
|
+
: [...BASE_KANA].map((kana) => ({ code: kanaTileCode(kana), glyph: kana, forms: CORNER_FORMS[kana] ?? "", count: JAPANESE_TILE_MIX[kana] ?? 0 }));
|
|
8
|
+
const counts = tiles.map((tile) => tile.count);
|
|
9
|
+
const most = Math.max(...counts);
|
|
10
|
+
const fewest = Math.min(...counts);
|
|
11
|
+
return {
|
|
12
|
+
tiles,
|
|
13
|
+
total: counts.reduce((sum, count) => sum + count, 0),
|
|
14
|
+
most,
|
|
15
|
+
fewest,
|
|
16
|
+
rarest: tiles.filter((tile) => tile.count === fewest).map((tile) => tile.glyph),
|
|
17
|
+
commonest: tiles.filter((tile) => tile.count === most).map((tile) => tile.glyph),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* EVERY HIRAGANA A TILE PLAYS AS, itself first: は is は, ば and ぱ; つ is つ,
|
|
22
|
+
* っ and づ; お is お, ぉ and を. Read off the rule the word check uses
|
|
23
|
+
* (`tileKana`) over the whole hiragana block, so the list is what the game
|
|
24
|
+
* accepts — the rare forms the corner leaves out (ぁ, ゎ, ゔ) included.
|
|
25
|
+
*/
|
|
26
|
+
const HIRAGANA_FIRST = 0x3041;
|
|
27
|
+
const HIRAGANA_LAST = 0x3096;
|
|
28
|
+
export function formsOfTile(kana) {
|
|
29
|
+
if (tileKana(kana) !== kana)
|
|
30
|
+
return [];
|
|
31
|
+
const forms = [kana];
|
|
32
|
+
for (let point = HIRAGANA_FIRST; point <= HIRAGANA_LAST; point += 1) {
|
|
33
|
+
const form = String.fromCodePoint(point);
|
|
34
|
+
if (form !== kana && tileKana(form) === kana)
|
|
35
|
+
forms.push(form);
|
|
36
|
+
}
|
|
37
|
+
return forms;
|
|
38
|
+
}
|
|
39
|
+
export const LENGTHS = ["short", "medium", "full"];
|
|
40
|
+
export function lengthRows(hand = KUMIMOJI_HANDS.classic) {
|
|
41
|
+
return LENGTHS.map((length) => {
|
|
42
|
+
const tiles = kumimojiTileCount(hand, length);
|
|
43
|
+
return {
|
|
44
|
+
length,
|
|
45
|
+
tiles,
|
|
46
|
+
doubleTiles: kumimojiTileCount(hand, length, undefined, true),
|
|
47
|
+
wilds: { easy: kumimojiWildCount(hand, "easy", tiles), medium: kumimojiWildCount(hand, "medium", tiles), hard: kumimojiWildCount(hand, "hard", tiles) },
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* THE TRY-IT ON THE FRONT DOOR: a Kumimoji of ten tiles, played in the
|
|
53
|
+
* reader's browser with the game's own moves and the game's own word check.
|
|
54
|
+
* A hand of seven, then three to draw, the last of them a wild. The bag is
|
|
55
|
+
* fixed rather than dealt, so the page is the same for everybody and needs no
|
|
56
|
+
* generator; `showcase.test.ts` proves it can be finished, with a crossword
|
|
57
|
+
* checked against the real list.
|
|
58
|
+
*/
|
|
59
|
+
export const TRY_IT = { bag: "rotenasdi*", hand: 7 };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type Tiles } from "./grid.ts";
|
|
2
|
+
/**
|
|
3
|
+
* HOW THE TABLE IS LOOKED AT: which squares are shown, how big a tile is
|
|
4
|
+
* drawn and where. Pure, and apart from the game (`play.ts`): the tiles are
|
|
5
|
+
* where the player put them whatever the view, and a view is a function of
|
|
6
|
+
* the tiles, the screen and the player's own zooming and panning.
|
|
7
|
+
*
|
|
8
|
+
* John, 2026-09-26: no board — "literally there is no board in the real world
|
|
9
|
+
* game." The table shown is the tiles' own extent plus a margin all round, so
|
|
10
|
+
* there is always room to build outward, and it zooms to fit the crossword:
|
|
11
|
+
* big tiles for a first word, smaller as the grid spreads, never smaller than
|
|
12
|
+
* a thumb can press (`TABLE.tileLeast`). Past that the table pans instead.
|
|
13
|
+
*/
|
|
14
|
+
export declare const TABLE: {
|
|
15
|
+
/** Empty squares shown beyond the tiles on every side. */
|
|
16
|
+
readonly margin: 2;
|
|
17
|
+
/** The smallest a tile is drawn, in CSS pixels: a comfortable thumb. */
|
|
18
|
+
readonly tileLeast: 32;
|
|
19
|
+
/**
|
|
20
|
+
* The smallest a player may zoom a tile to by hand, to see a big crossword
|
|
21
|
+
* whole: too small to press, and not meant to be (John, 2026-09-28: "I would
|
|
22
|
+
* like to zoom out even further"). Fit never goes below `tileLeast`.
|
|
23
|
+
*/
|
|
24
|
+
readonly zoomLeast: 12;
|
|
25
|
+
/** The largest a fitted tile is drawn: a first word does not fill a desk. */
|
|
26
|
+
readonly tileMost: 56;
|
|
27
|
+
/**
|
|
28
|
+
* The shorter side of a table past which `tileMost` grows with it (`fittedMost`):
|
|
29
|
+
* the Regular desk table's height, 34rem. No phone's or tablet's table is taller
|
|
30
|
+
* or wider across its shorter side, so none changes; only a table the player made
|
|
31
|
+
* bigger with the board size chooser (`BoardScale`) grows its squares, in
|
|
32
|
+
* proportion to the table everybody had.
|
|
33
|
+
*/
|
|
34
|
+
readonly growsFrom: 544;
|
|
35
|
+
/** The largest a player may zoom a tile to by hand. */
|
|
36
|
+
readonly zoomMost: 88;
|
|
37
|
+
/** How near an edge of the table a dragged tile starts it panning, and how fast, in pixels a frame. */
|
|
38
|
+
readonly edge: 36;
|
|
39
|
+
readonly edgeStep: 8;
|
|
40
|
+
/** How long a dragged tile must stay near an edge before the table pans: crossing an edge on the way in is not asking to pan. */
|
|
41
|
+
readonly edgeDwellMs: 350;
|
|
42
|
+
};
|
|
43
|
+
/** The squares shown: from `top`/`left`, `rows` by `cols`. */
|
|
44
|
+
export type Area = {
|
|
45
|
+
top: number;
|
|
46
|
+
left: number;
|
|
47
|
+
rows: number;
|
|
48
|
+
cols: number;
|
|
49
|
+
};
|
|
50
|
+
/** A view: a tile's side in pixels, and where the corner of square (0, 0) is drawn. */
|
|
51
|
+
export type View = {
|
|
52
|
+
tile: number;
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
};
|
|
56
|
+
/** The squares to show: the tiles' extent and the margin, or the margin round square (0, 0) on an empty table. Extra squares (the one a typed letter goes to) are kept in. */
|
|
57
|
+
export declare function tableArea(tiles: Tiles, also?: readonly string[]): Area;
|
|
58
|
+
/**
|
|
59
|
+
* The largest a fitted tile is drawn in a table this big. `tileMost` in any
|
|
60
|
+
* table up to `growsFrom` across its shorter side, which is every phone's and
|
|
61
|
+
* the Regular desk table; past that, in proportion, so a table the player asked
|
|
62
|
+
* to be Large or Full (`BoardScale`) draws bigger squares rather than only more
|
|
63
|
+
* of them — never past what a hand may zoom to.
|
|
64
|
+
*/
|
|
65
|
+
export declare function fittedMost(width: number, height: number): number;
|
|
66
|
+
/**
|
|
67
|
+
* The view that shows the whole area, centred, its tiles as big as fit between
|
|
68
|
+
* the least and the most. A picture nobody presses (a finished grid, the
|
|
69
|
+
* set-up preview) passes a smaller least, so a whole grid fits its box.
|
|
70
|
+
*/
|
|
71
|
+
export declare function fitView(area: Area, width: number, height: number, least?: number): View;
|
|
72
|
+
/** Whether the area, at this view's size, is bigger than the screen: then the table pans. */
|
|
73
|
+
export declare function overflows(area: Area, view: View, width: number, height: number): boolean;
|
|
74
|
+
/** Zoomed by `factor` about the point (px, py) on the screen, which stays over the same spot of the table. */
|
|
75
|
+
export declare function zoomView(view: View, factor: number, px: number, py: number): View;
|
|
76
|
+
export declare function panView(view: View, dx: number, dy: number): View;
|
|
77
|
+
/**
|
|
78
|
+
* Kept within reach: some of the area always on the screen, so a pan can
|
|
79
|
+
* never lose the crossword. A quarter of the screen or two tiles, whichever
|
|
80
|
+
* is less, stays in view on every side.
|
|
81
|
+
*/
|
|
82
|
+
export declare function keepInReach(view: View, area: Area, width: number, height: number): View;
|
|
83
|
+
/** The square under a point on the screen. */
|
|
84
|
+
export declare function squareUnder(view: View, px: number, py: number): {
|
|
85
|
+
row: number;
|
|
86
|
+
col: number;
|
|
87
|
+
};
|
|
88
|
+
/** Which way, and how far, the table pans this frame for a tile dragged to (px, py): toward the edge it is near. */
|
|
89
|
+
export declare function edgePan(px: number, py: number, width: number, height: number): {
|
|
90
|
+
dx: number;
|
|
91
|
+
dy: number;
|
|
92
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { boundsOf, placeOf } from "./grid.js";
|
|
2
|
+
/**
|
|
3
|
+
* HOW THE TABLE IS LOOKED AT: which squares are shown, how big a tile is
|
|
4
|
+
* drawn and where. Pure, and apart from the game (`play.ts`): the tiles are
|
|
5
|
+
* where the player put them whatever the view, and a view is a function of
|
|
6
|
+
* the tiles, the screen and the player's own zooming and panning.
|
|
7
|
+
*
|
|
8
|
+
* John, 2026-09-26: no board — "literally there is no board in the real world
|
|
9
|
+
* game." The table shown is the tiles' own extent plus a margin all round, so
|
|
10
|
+
* there is always room to build outward, and it zooms to fit the crossword:
|
|
11
|
+
* big tiles for a first word, smaller as the grid spreads, never smaller than
|
|
12
|
+
* a thumb can press (`TABLE.tileLeast`). Past that the table pans instead.
|
|
13
|
+
*/
|
|
14
|
+
export const TABLE = {
|
|
15
|
+
/** Empty squares shown beyond the tiles on every side. */
|
|
16
|
+
margin: 2,
|
|
17
|
+
/** The smallest a tile is drawn, in CSS pixels: a comfortable thumb. */
|
|
18
|
+
tileLeast: 32,
|
|
19
|
+
/**
|
|
20
|
+
* The smallest a player may zoom a tile to by hand, to see a big crossword
|
|
21
|
+
* whole: too small to press, and not meant to be (John, 2026-09-28: "I would
|
|
22
|
+
* like to zoom out even further"). Fit never goes below `tileLeast`.
|
|
23
|
+
*/
|
|
24
|
+
zoomLeast: 12,
|
|
25
|
+
/** The largest a fitted tile is drawn: a first word does not fill a desk. */
|
|
26
|
+
tileMost: 56,
|
|
27
|
+
/**
|
|
28
|
+
* The shorter side of a table past which `tileMost` grows with it (`fittedMost`):
|
|
29
|
+
* the Regular desk table's height, 34rem. No phone's or tablet's table is taller
|
|
30
|
+
* or wider across its shorter side, so none changes; only a table the player made
|
|
31
|
+
* bigger with the board size chooser (`BoardScale`) grows its squares, in
|
|
32
|
+
* proportion to the table everybody had.
|
|
33
|
+
*/
|
|
34
|
+
growsFrom: 544,
|
|
35
|
+
/** The largest a player may zoom a tile to by hand. */
|
|
36
|
+
zoomMost: 88,
|
|
37
|
+
/** How near an edge of the table a dragged tile starts it panning, and how fast, in pixels a frame. */
|
|
38
|
+
edge: 36,
|
|
39
|
+
edgeStep: 8,
|
|
40
|
+
/** How long a dragged tile must stay near an edge before the table pans: crossing an edge on the way in is not asking to pan. */
|
|
41
|
+
edgeDwellMs: 350,
|
|
42
|
+
};
|
|
43
|
+
/** The squares to show: the tiles' extent and the margin, or the margin round square (0, 0) on an empty table. Extra squares (the one a typed letter goes to) are kept in. */
|
|
44
|
+
export function tableArea(tiles, also = []) {
|
|
45
|
+
const bounds = boundsOf(tiles) ?? { top: 0, left: 0, bottom: 0, right: 0 };
|
|
46
|
+
let { top, left, bottom, right } = bounds;
|
|
47
|
+
for (const square of also) {
|
|
48
|
+
const { row, col } = placeOf(square);
|
|
49
|
+
top = Math.min(top, row);
|
|
50
|
+
left = Math.min(left, col);
|
|
51
|
+
bottom = Math.max(bottom, row);
|
|
52
|
+
right = Math.max(right, col);
|
|
53
|
+
}
|
|
54
|
+
const m = TABLE.margin;
|
|
55
|
+
return { top: top - m, left: left - m, rows: bottom - top + 1 + 2 * m, cols: right - left + 1 + 2 * m };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The largest a fitted tile is drawn in a table this big. `tileMost` in any
|
|
59
|
+
* table up to `growsFrom` across its shorter side, which is every phone's and
|
|
60
|
+
* the Regular desk table; past that, in proportion, so a table the player asked
|
|
61
|
+
* to be Large or Full (`BoardScale`) draws bigger squares rather than only more
|
|
62
|
+
* of them — never past what a hand may zoom to.
|
|
63
|
+
*/
|
|
64
|
+
export function fittedMost(width, height) {
|
|
65
|
+
const side = Math.min(width, height);
|
|
66
|
+
if (!(side > TABLE.growsFrom))
|
|
67
|
+
return TABLE.tileMost;
|
|
68
|
+
return Math.min(TABLE.zoomMost, Math.floor((TABLE.tileMost * side) / TABLE.growsFrom));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The view that shows the whole area, centred, its tiles as big as fit between
|
|
72
|
+
* the least and the most. A picture nobody presses (a finished grid, the
|
|
73
|
+
* set-up preview) passes a smaller least, so a whole grid fits its box.
|
|
74
|
+
*/
|
|
75
|
+
export function fitView(area, width, height, least = TABLE.tileLeast) {
|
|
76
|
+
const fits = Math.min(width / area.cols, height / area.rows);
|
|
77
|
+
const tile = Math.max(least, Math.min(fittedMost(width, height), Math.floor(fits)));
|
|
78
|
+
return {
|
|
79
|
+
tile,
|
|
80
|
+
x: Math.round((width - area.cols * tile) / 2 - area.left * tile),
|
|
81
|
+
y: Math.round((height - area.rows * tile) / 2 - area.top * tile),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Whether the area, at this view's size, is bigger than the screen: then the table pans. */
|
|
85
|
+
export function overflows(area, view, width, height) {
|
|
86
|
+
return area.cols * view.tile > width + 0.5 || area.rows * view.tile > height + 0.5;
|
|
87
|
+
}
|
|
88
|
+
/** Zoomed by `factor` about the point (px, py) on the screen, which stays over the same spot of the table. */
|
|
89
|
+
export function zoomView(view, factor, px, py) {
|
|
90
|
+
const tile = Math.max(TABLE.zoomLeast, Math.min(TABLE.zoomMost, view.tile * factor));
|
|
91
|
+
const scale = tile / view.tile;
|
|
92
|
+
return { tile, x: px - (px - view.x) * scale, y: py - (py - view.y) * scale };
|
|
93
|
+
}
|
|
94
|
+
export function panView(view, dx, dy) {
|
|
95
|
+
return { ...view, x: view.x + dx, y: view.y + dy };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Kept within reach: some of the area always on the screen, so a pan can
|
|
99
|
+
* never lose the crossword. A quarter of the screen or two tiles, whichever
|
|
100
|
+
* is less, stays in view on every side.
|
|
101
|
+
*/
|
|
102
|
+
export function keepInReach(view, area, width, height) {
|
|
103
|
+
const hold = (span) => Math.min(span / 4, view.tile * 2);
|
|
104
|
+
const left = view.x + area.left * view.tile;
|
|
105
|
+
const top = view.y + area.top * view.tile;
|
|
106
|
+
const right = left + area.cols * view.tile;
|
|
107
|
+
const bottom = top + area.rows * view.tile;
|
|
108
|
+
let { x, y } = view;
|
|
109
|
+
if (right < hold(width))
|
|
110
|
+
x += hold(width) - right;
|
|
111
|
+
if (left > width - hold(width))
|
|
112
|
+
x -= left - (width - hold(width));
|
|
113
|
+
if (bottom < hold(height))
|
|
114
|
+
y += hold(height) - bottom;
|
|
115
|
+
if (top > height - hold(height))
|
|
116
|
+
y -= top - (height - hold(height));
|
|
117
|
+
return { ...view, x, y };
|
|
118
|
+
}
|
|
119
|
+
/** The square under a point on the screen. */
|
|
120
|
+
export function squareUnder(view, px, py) {
|
|
121
|
+
return { row: Math.floor((py - view.y) / view.tile), col: Math.floor((px - view.x) / view.tile) };
|
|
122
|
+
}
|
|
123
|
+
/** Which way, and how far, the table pans this frame for a tile dragged to (px, py): toward the edge it is near. */
|
|
124
|
+
export function edgePan(px, py, width, height) {
|
|
125
|
+
const step = (near) => (near < TABLE.edge ? TABLE.edgeStep * (1 - Math.max(0, near) / TABLE.edge) : 0);
|
|
126
|
+
return { dx: step(px) - step(width - px), dy: step(py) - step(height - py) };
|
|
127
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A TILE CODE SHOWS, without the word list. A bag, a kept game and a
|
|
3
|
+
* finished grid hold one character a tile: an English letter as itself (a wild
|
|
4
|
+
* given a letter is that letter in capitals, a wild with none is `*`), and a
|
|
5
|
+
* Japanese kana as a private-use character, its place in `BASE_KANA` above
|
|
6
|
+
* `KANA_TILE_START`, or above `KANA_WILD_START` for a wild given that kana.
|
|
7
|
+
* So the set-up's preview and a finished game's page draw Japanese tiles
|
|
8
|
+
* without fetching the dictionary, and `tileWords` reads the same codes.
|
|
9
|
+
*/
|
|
10
|
+
export declare const KANA_TILE_START = 57344;
|
|
11
|
+
export declare const KANA_WILD_START = 61440;
|
|
12
|
+
/** The code of a base kana's tile, or of a wild given that kana. */
|
|
13
|
+
export declare function kanaTileCode(kana: string, wild?: boolean): string | null;
|
|
14
|
+
export type TileFaceOf = {
|
|
15
|
+
/** The letter or kana printed on it: 五 on a wild nobody has given one. */
|
|
16
|
+
glyph: string;
|
|
17
|
+
/** The other forms it plays as, small in its corner (`CORNER_FORMS`). */
|
|
18
|
+
forms: string;
|
|
19
|
+
wild: boolean;
|
|
20
|
+
/** A wild with no letter yet. */
|
|
21
|
+
blank: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare function tileFace(tile: string): TileFaceOf;
|
|
24
|
+
/** What a screen reader says for a tile: its letter, or that it is a wild and what it stands for. */
|
|
25
|
+
export declare function tileDescription(tile: string): string;
|