@joystyk/cards-ui 0.0.0-stage → 0.2.0
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 +22 -0
- package/LICENSE +21 -0
- package/NOTICE +36 -0
- package/README.md +32 -2
- package/dist/controller.d.ts +110 -0
- package/dist/controller.js +449 -0
- package/dist/controller.js.map +1 -0
- package/dist/face-styles.d.ts +1 -0
- package/dist/face-styles.js +106 -0
- package/dist/face-styles.js.map +1 -0
- package/dist/faces.d.ts +11 -0
- package/dist/faces.js +403 -0
- package/dist/faces.js.map +1 -0
- package/dist/hand-styles.d.ts +1 -0
- package/dist/hand-styles.js +121 -0
- package/dist/hand-styles.js.map +1 -0
- package/dist/hand.d.ts +100 -0
- package/dist/hand.js +480 -0
- package/dist/hand.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/layout.d.ts +122 -0
- package/dist/layout.js +35 -0
- package/dist/layout.js.map +1 -0
- package/dist/solitaire.d.ts +155 -0
- package/dist/solitaire.js +1508 -0
- package/dist/solitaire.js.map +1 -0
- package/dist/styles.d.ts +1 -0
- package/dist/styles.js +452 -0
- package/dist/styles.js.map +1 -0
- package/dist/table.d.ts +28 -0
- package/dist/table.js +629 -0
- package/dist/table.js.map +1 -0
- package/dist/tiles.d.ts +3 -0
- package/dist/tiles.js +157 -0
- package/dist/tiles.js.map +1 -0
- package/dist/worker-setup.d.ts +7 -0
- package/dist/worker-setup.js +45 -0
- package/dist/worker-setup.js.map +1 -0
- package/package.json +67 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @joystyk/cards-ui
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [bb095c7]
|
|
8
|
+
- Updated dependencies [bb095c7]
|
|
9
|
+
- @joystyk/contracts@0.2.0
|
|
10
|
+
- @joystyk/kit@0.2.0
|
|
11
|
+
|
|
12
|
+
## 0.1.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies
|
|
21
|
+
- @joystyk/contracts@0.1.0
|
|
22
|
+
- @joystyk/kit@0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joystyk contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Third-party material in the Joystyk library release
|
|
2
|
+
|
|
3
|
+
This notice preserves third-party attribution. Original Joystyk code in the 17
|
|
4
|
+
platform libraries under packages/ uses the MIT license in packages/LICENSE.
|
|
5
|
+
That choice does not license the private applications, games or other repository
|
|
6
|
+
material, and does not replace the third-party terms below.
|
|
7
|
+
|
|
8
|
+
@joystyk/contracts
|
|
9
|
+
------------------
|
|
10
|
+
Standard Schema v1 type definitions from @standard-schema/spec 1.1.0:
|
|
11
|
+
https://github.com/standard-schema/standard-schema
|
|
12
|
+
https://standardschema.dev
|
|
13
|
+
Copyright (c) 2024 Colin McDonnell. MIT License.
|
|
14
|
+
The complete license and source/change notes accompany the distributed
|
|
15
|
+
dist/standard-schema.d.ts definitions. Changes: individually exported declarations
|
|
16
|
+
and formatting; the declarations themselves are unchanged.
|
|
17
|
+
|
|
18
|
+
@joystyk/identity and @joystyk/content
|
|
19
|
+
-----------------------------------
|
|
20
|
+
Word blocklists derived from List of Dirty, Naughty, Obscene, and Otherwise Bad
|
|
21
|
+
Words, copyright Shutterstock, Inc. and contributors:
|
|
22
|
+
https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
|
|
23
|
+
Licensed under Creative Commons Attribution 4.0 International:
|
|
24
|
+
https://creativecommons.org/licenses/by/4.0/
|
|
25
|
+
Identity uses the English list at commit
|
|
26
|
+
5faf2ba42d7b1c0977169ec3611df25a3c08eb13. Changes include reducing or dropping
|
|
27
|
+
phrases, dropping words with everyday meanings, substituting stems for inflections
|
|
28
|
+
and adding entries. Identity's README and content's lists/blocklist.txt retain
|
|
29
|
+
their source-specific provenance and changes.
|
|
30
|
+
|
|
31
|
+
@joystyk/content
|
|
32
|
+
---------------
|
|
33
|
+
SCOWL-derived common, vocabulary and ranked word lists use SCOWL 2020.12.07.
|
|
34
|
+
Their complete source licenses and attribution accompany the package in
|
|
35
|
+
lists/SCOWL-COPYRIGHT. Word-list transformations and build provenance are described
|
|
36
|
+
in the package README. Preserve that copyright file when redistributing the lists.
|
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @joystyk/cards-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared card tables, hands and solitaire controls for Gyral games. A game supplies a public table layout and intentions; these components handle pointer, keyboard and screen-reader interaction. Keep game authority in the engine.
|
|
4
|
+
|
|
5
|
+
## npm package
|
|
6
|
+
|
|
7
|
+
The first `0.1.0` release is being prepared; it has not been published. Once available:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @joystyk/cards-ui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node consumers require Node 24.15 or later. Browser entry points require a modern
|
|
14
|
+
browser and should be loaded in the browser. Tested with Gyral 0.3.1.
|
|
15
|
+
|
|
16
|
+
| Import | Format |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `@joystyk/cards-ui` | ES module and TypeScript declarations |
|
|
19
|
+
| `@joystyk/cards-ui/controller` | ES module and TypeScript declarations |
|
|
20
|
+
| `@joystyk/cards-ui/layout` | ES module and TypeScript declarations |
|
|
21
|
+
| `@joystyk/cards-ui/solitaire` | ES module and TypeScript declarations |
|
|
22
|
+
|
|
23
|
+
Only the entries listed above are public API. Installed packages contain compiled ESM,
|
|
24
|
+
type declarations and runtime assets. Repository-relative links describe the Joystyk
|
|
25
|
+
workspace and are not needed to use the npm package.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { CardTable, CardHand } from '@joystyk/cards-ui';
|
|
29
|
+
import { EMPTY_LAYOUT, DEFAULT_OPTIONS } from '@joystyk/cards-ui/layout';
|
|
30
|
+
|
|
31
|
+
// CardTable and CardHand register <jy-card-table> and <jy-card-hand>.
|
|
32
|
+
// Bind the layout/options properties from your Gyral parent.
|
|
33
|
+
```
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type FaceCard, type PileSpec, type TableLayout, type TableOptions } from './layout.js';
|
|
2
|
+
/** Pointer travel, in CSS pixels, that turns a press into a drag (spec: Pointer). */
|
|
3
|
+
export declare const DRAG_THRESHOLD = 6;
|
|
4
|
+
export type Via = 'drag' | 'click' | 'keyboard';
|
|
5
|
+
/** Cards the player has picked up: the top `count` of pile `from`. */
|
|
6
|
+
export type Held = {
|
|
7
|
+
readonly from: string;
|
|
8
|
+
readonly count: number;
|
|
9
|
+
readonly via: Via;
|
|
10
|
+
};
|
|
11
|
+
/** A pointer pressed on a pile; `depth` counts cards from the top (0: the pile, not a card). */
|
|
12
|
+
export type Press = {
|
|
13
|
+
readonly pile: string | null;
|
|
14
|
+
readonly depth: number;
|
|
15
|
+
readonly x: number;
|
|
16
|
+
readonly y: number;
|
|
17
|
+
readonly dragging: boolean;
|
|
18
|
+
readonly dx: number;
|
|
19
|
+
readonly dy: number;
|
|
20
|
+
readonly over: string | null;
|
|
21
|
+
};
|
|
22
|
+
export type Ctl = {
|
|
23
|
+
/** The focused region. */
|
|
24
|
+
readonly region: number;
|
|
25
|
+
/** The remembered pile of each region: the one Tab reaches (roving tabindex). */
|
|
26
|
+
readonly piles: readonly number[];
|
|
27
|
+
/** Cards counted from the top of the focused pile: what Enter picks up. */
|
|
28
|
+
readonly depth: number;
|
|
29
|
+
readonly held: Held | null;
|
|
30
|
+
readonly press: Press | null;
|
|
31
|
+
/** The layout version last seen. */
|
|
32
|
+
readonly version: number;
|
|
33
|
+
/** A drag just dropped: the next layout lands without card flights. */
|
|
34
|
+
readonly settle: boolean;
|
|
35
|
+
/** The layout version that renders without card flights. */
|
|
36
|
+
readonly quiet: number | null;
|
|
37
|
+
};
|
|
38
|
+
/** What the table tells the game. */
|
|
39
|
+
export type TableOut = {
|
|
40
|
+
readonly _tag: 'Move';
|
|
41
|
+
readonly from: string;
|
|
42
|
+
readonly to: string;
|
|
43
|
+
readonly count: number;
|
|
44
|
+
readonly via: Via;
|
|
45
|
+
} | {
|
|
46
|
+
readonly _tag: 'Smart';
|
|
47
|
+
readonly from: string;
|
|
48
|
+
readonly count: number;
|
|
49
|
+
readonly via: Via;
|
|
50
|
+
} | {
|
|
51
|
+
readonly _tag: 'Activate';
|
|
52
|
+
readonly pile: string;
|
|
53
|
+
} | {
|
|
54
|
+
readonly _tag: 'Key';
|
|
55
|
+
readonly key: string;
|
|
56
|
+
readonly pile: string;
|
|
57
|
+
readonly count: number;
|
|
58
|
+
} | {
|
|
59
|
+
readonly _tag: 'Announce';
|
|
60
|
+
readonly message: string;
|
|
61
|
+
};
|
|
62
|
+
export type CtlMsg = {
|
|
63
|
+
readonly _tag: 'Key';
|
|
64
|
+
readonly key: string;
|
|
65
|
+
readonly ctrl: boolean;
|
|
66
|
+
readonly alt: boolean;
|
|
67
|
+
readonly meta: boolean;
|
|
68
|
+
readonly shift: boolean;
|
|
69
|
+
} | {
|
|
70
|
+
readonly _tag: 'Focus';
|
|
71
|
+
readonly pile: string;
|
|
72
|
+
} | {
|
|
73
|
+
readonly _tag: 'Down';
|
|
74
|
+
readonly pile: string | null;
|
|
75
|
+
readonly depth: number;
|
|
76
|
+
readonly x: number;
|
|
77
|
+
readonly y: number;
|
|
78
|
+
} | {
|
|
79
|
+
readonly _tag: 'Move';
|
|
80
|
+
readonly x: number;
|
|
81
|
+
readonly y: number;
|
|
82
|
+
readonly over: string | null;
|
|
83
|
+
} | {
|
|
84
|
+
readonly _tag: 'Up';
|
|
85
|
+
readonly over: string | null;
|
|
86
|
+
} | {
|
|
87
|
+
readonly _tag: 'Cancel';
|
|
88
|
+
};
|
|
89
|
+
/** One step: the new controller state, outputs, and a pile to focus after rendering. */
|
|
90
|
+
export type Step = {
|
|
91
|
+
readonly ctl: Ctl;
|
|
92
|
+
readonly outs: readonly TableOut[];
|
|
93
|
+
readonly focus: string | null;
|
|
94
|
+
};
|
|
95
|
+
export declare const initialCtl: (layout: TableLayout) => Ctl;
|
|
96
|
+
/**
|
|
97
|
+
* Brings the controller in line with a new layout: indexes stay in range, and a new version
|
|
98
|
+
* (the cards changed) drops any pick-up or press.
|
|
99
|
+
*/
|
|
100
|
+
export declare const sync: (ctl: Ctl, layout: TableLayout) => Ctl;
|
|
101
|
+
/** The focused pile, if the table has any. */
|
|
102
|
+
export declare const focusedPile: (ctl: Ctl, layout: TableLayout) => PileSpec | null;
|
|
103
|
+
/** A card's name for the middle of a sentence: "queen of hearts", "7 of clubs". */
|
|
104
|
+
export declare const spoken: (card: FaceCard) => string;
|
|
105
|
+
/** "9 of spades", or "9 of spades and 2 more cards": the cards a pick-up of `count` takes. */
|
|
106
|
+
export declare const describe: (pile: PileSpec, count: number) => string;
|
|
107
|
+
/** The controller's reducer. */
|
|
108
|
+
export declare const step: (before: Ctl, layout: TableLayout, options: TableOptions, m: CtlMsg) => Step;
|
|
109
|
+
/** True when the table should render this layout without card flights. */
|
|
110
|
+
export declare const isQuiet: (ctl: Ctl, layout: TableLayout, options: TableOptions) => boolean;
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { findPile, isFaceCard, topCards } from "./layout.js";
|
|
2
|
+
import { cardName } from "@joystyk/kit/cards";
|
|
3
|
+
//#region src/controller.ts
|
|
4
|
+
/** Pointer travel, in CSS pixels, that turns a press into a drag (spec: Pointer). */
|
|
5
|
+
var DRAG_THRESHOLD = 6;
|
|
6
|
+
var initialCtl = (layout) => {
|
|
7
|
+
const first = layout.regions.findIndex((r) => r.piles.length > 0);
|
|
8
|
+
return {
|
|
9
|
+
region: Math.max(0, first),
|
|
10
|
+
piles: layout.regions.map(() => 0),
|
|
11
|
+
depth: 1,
|
|
12
|
+
held: null,
|
|
13
|
+
press: null,
|
|
14
|
+
version: layout.version,
|
|
15
|
+
settle: false,
|
|
16
|
+
quiet: null
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
var clamp = (n, min, max) => Math.min(max, Math.max(min, n));
|
|
20
|
+
/**
|
|
21
|
+
* Brings the controller in line with a new layout: indexes stay in range, and a new version
|
|
22
|
+
* (the cards changed) drops any pick-up or press.
|
|
23
|
+
*/
|
|
24
|
+
var sync = (ctl, layout) => {
|
|
25
|
+
const piles = layout.regions.map((r, i) => clamp(ctl.piles[i] ?? 0, 0, Math.max(0, r.piles.length - 1)));
|
|
26
|
+
let region = clamp(ctl.region, 0, Math.max(0, layout.regions.length - 1));
|
|
27
|
+
if ((layout.regions[region]?.piles.length ?? 0) === 0) region = Math.max(0, layout.regions.findIndex((r) => r.piles.length > 0));
|
|
28
|
+
const moved = layout.version !== ctl.version;
|
|
29
|
+
const same = piles.length === ctl.piles.length && piles.every((p, i) => p === ctl.piles[i]) && region === ctl.region;
|
|
30
|
+
if (!moved && same) return ctl;
|
|
31
|
+
if (!moved) return {
|
|
32
|
+
...ctl,
|
|
33
|
+
piles,
|
|
34
|
+
region
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
...ctl,
|
|
38
|
+
piles,
|
|
39
|
+
region,
|
|
40
|
+
depth: 1,
|
|
41
|
+
held: null,
|
|
42
|
+
press: null,
|
|
43
|
+
version: layout.version,
|
|
44
|
+
settle: false,
|
|
45
|
+
quiet: ctl.settle ? layout.version : null
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** The focused pile, if the table has any. */
|
|
49
|
+
var focusedPile = (ctl, layout) => layout.regions[ctl.region]?.piles[ctl.piles[ctl.region] ?? 0] ?? null;
|
|
50
|
+
/** A card's name for the middle of a sentence: "queen of hearts", "7 of clubs". */
|
|
51
|
+
var spoken = (card) => cardName(card).toLowerCase();
|
|
52
|
+
var capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
53
|
+
/** "9 of spades", or "9 of spades and 2 more cards": the cards a pick-up of `count` takes. */
|
|
54
|
+
var describe = (pile, count) => {
|
|
55
|
+
const [first] = topCards(pile, count);
|
|
56
|
+
if (first === void 0) return "nothing";
|
|
57
|
+
const more = count - 1;
|
|
58
|
+
return more === 0 ? spoken(first) : `${spoken(first)} and ${more} more ${more === 1 ? "card" : "cards"}`;
|
|
59
|
+
};
|
|
60
|
+
var none = (ctl) => ({
|
|
61
|
+
ctl,
|
|
62
|
+
outs: [],
|
|
63
|
+
focus: null
|
|
64
|
+
});
|
|
65
|
+
var say = (message) => ({
|
|
66
|
+
_tag: "Announce",
|
|
67
|
+
message
|
|
68
|
+
});
|
|
69
|
+
/** Picks up `count` cards from `pile` and says so. */
|
|
70
|
+
var pickUp = (ctl, pile, count, via) => {
|
|
71
|
+
const note = pile.note === void 0 ? "" : ` ${pile.note}`;
|
|
72
|
+
const help = via === "keyboard" ? " Choose a pile, then press Enter." : "";
|
|
73
|
+
return {
|
|
74
|
+
ctl: {
|
|
75
|
+
...ctl,
|
|
76
|
+
held: {
|
|
77
|
+
from: pile.id,
|
|
78
|
+
count,
|
|
79
|
+
via
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
outs: [say(`Picked up ${describe(pile, count)}.${note}${help}`)],
|
|
83
|
+
focus: null
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
/** Drops the held cards on `to`: a move, or a smart move on their own pile. */
|
|
87
|
+
var drop = (ctl, held, to, via) => {
|
|
88
|
+
return {
|
|
89
|
+
ctl: {
|
|
90
|
+
...ctl,
|
|
91
|
+
held: null
|
|
92
|
+
},
|
|
93
|
+
outs: [to === held.from ? {
|
|
94
|
+
_tag: "Smart",
|
|
95
|
+
from: held.from,
|
|
96
|
+
count: held.count,
|
|
97
|
+
via
|
|
98
|
+
} : {
|
|
99
|
+
_tag: "Move",
|
|
100
|
+
from: held.from,
|
|
101
|
+
to,
|
|
102
|
+
count: held.count,
|
|
103
|
+
via
|
|
104
|
+
}],
|
|
105
|
+
focus: null
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
var putBack = (ctl, layout) => {
|
|
109
|
+
const held = ctl.held;
|
|
110
|
+
if (held === null) return none(ctl);
|
|
111
|
+
const pile = findPile(layout, held.from)?.pile;
|
|
112
|
+
return {
|
|
113
|
+
ctl: {
|
|
114
|
+
...ctl,
|
|
115
|
+
held: null
|
|
116
|
+
},
|
|
117
|
+
outs: [say(pile === void 0 ? "Put back." : `Put back ${describe(pile, held.count)}.`)],
|
|
118
|
+
focus: null
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
/** Moves the focus to pile `index` of the focused region. */
|
|
122
|
+
var focusAt = (ctl, layout, region, index) => {
|
|
123
|
+
const pile = layout.regions[region]?.piles[index];
|
|
124
|
+
if (pile === void 0) return none(ctl);
|
|
125
|
+
const piles = ctl.piles.slice();
|
|
126
|
+
piles[region] = index;
|
|
127
|
+
const changed = region !== ctl.region || index !== ctl.piles[region];
|
|
128
|
+
return {
|
|
129
|
+
ctl: {
|
|
130
|
+
...ctl,
|
|
131
|
+
region,
|
|
132
|
+
piles,
|
|
133
|
+
depth: changed ? 1 : ctl.depth
|
|
134
|
+
},
|
|
135
|
+
outs: [],
|
|
136
|
+
focus: pile.id
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
/** Enter or Space on the focused pile. */
|
|
140
|
+
var activate = (ctl, pile) => {
|
|
141
|
+
if (ctl.held !== null) return drop(ctl, ctl.held, pile.id, "keyboard");
|
|
142
|
+
if (pile.action === true) return {
|
|
143
|
+
ctl,
|
|
144
|
+
outs: [{
|
|
145
|
+
_tag: "Activate",
|
|
146
|
+
pile: pile.id
|
|
147
|
+
}],
|
|
148
|
+
focus: null
|
|
149
|
+
};
|
|
150
|
+
if (pile.pickable >= 1) return pickUp(ctl, pile, clamp(ctl.depth, 1, pile.pickable), "keyboard");
|
|
151
|
+
return {
|
|
152
|
+
ctl,
|
|
153
|
+
outs: [say(pile.cards.length === 0 ? "Nothing to pick up: the pile is empty." : "Nothing here can be picked up.")],
|
|
154
|
+
focus: null
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
/** Up and Down: how many cards Enter picks up. */
|
|
158
|
+
var deepen = (ctl, pile, by) => {
|
|
159
|
+
if (pile.inspect === true && ctl.held === null) {
|
|
160
|
+
const depth = clamp(ctl.depth + by, 1, Math.max(1, pile.cards.length));
|
|
161
|
+
const card = pile.cards[pile.cards.length - depth];
|
|
162
|
+
return {
|
|
163
|
+
ctl: {
|
|
164
|
+
...ctl,
|
|
165
|
+
depth
|
|
166
|
+
},
|
|
167
|
+
outs: isFaceCard(card) ? [say(capital(spoken(card)))] : [],
|
|
168
|
+
focus: null
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (ctl.held !== null || pile.pickable < 1) return none(ctl);
|
|
172
|
+
const depth = clamp(ctl.depth, 1, pile.pickable);
|
|
173
|
+
const next = depth + by;
|
|
174
|
+
if (next < 1) return {
|
|
175
|
+
ctl: {
|
|
176
|
+
...ctl,
|
|
177
|
+
depth
|
|
178
|
+
},
|
|
179
|
+
outs: [],
|
|
180
|
+
focus: null
|
|
181
|
+
};
|
|
182
|
+
if (next > pile.pickable) return {
|
|
183
|
+
ctl: {
|
|
184
|
+
...ctl,
|
|
185
|
+
depth
|
|
186
|
+
},
|
|
187
|
+
outs: pile.limit === void 0 ? [] : [say(pile.limit)],
|
|
188
|
+
focus: null
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
ctl: {
|
|
192
|
+
...ctl,
|
|
193
|
+
depth: next
|
|
194
|
+
},
|
|
195
|
+
outs: [say(capital(describe(pile, next)))],
|
|
196
|
+
focus: null
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
var isDigit = (key) => key.length === 1 && key >= "0" && key <= "9";
|
|
200
|
+
/** Navigate a geometric layout without consulting the DOM or skipping covered cards. */
|
|
201
|
+
var geometricFocus = (ctl, layout, key) => {
|
|
202
|
+
const region = layout.regions[ctl.region];
|
|
203
|
+
const here = focusedPile(ctl, layout)?.position;
|
|
204
|
+
if (region?.placement !== "free" || here === void 0) return null;
|
|
205
|
+
const candidates = region.piles.map((pile, index) => ({
|
|
206
|
+
index,
|
|
207
|
+
position: pile.position
|
|
208
|
+
})).filter((p) => p.position !== void 0);
|
|
209
|
+
const row = key === "ArrowUp" ? Math.max(-1, ...candidates.filter((p) => p.position.row < here.row).map((p) => p.position.row)) : key === "ArrowDown" ? Math.min(Infinity, ...candidates.filter((p) => p.position.row > here.row).map((p) => p.position.row)) : here.row;
|
|
210
|
+
let choices = candidates.filter((p) => p.position.row === row);
|
|
211
|
+
if (key === "ArrowLeft") choices = choices.filter((p) => p.position.column < here.column);
|
|
212
|
+
if (key === "ArrowRight") choices = choices.filter((p) => p.position.column > here.column);
|
|
213
|
+
choices.sort((a, b) => key === "Home" ? a.position.column - b.position.column : key === "End" ? b.position.column - a.position.column : Math.abs(a.position.column - here.column) - Math.abs(b.position.column - here.column) || a.position.column - b.position.column);
|
|
214
|
+
return choices[0] === void 0 ? none(ctl) : focusAt(ctl, layout, ctl.region, choices[0].index);
|
|
215
|
+
};
|
|
216
|
+
var onKey = (ctl, layout, options, m) => {
|
|
217
|
+
const pile = focusedPile(ctl, layout);
|
|
218
|
+
if (pile === null || m.alt || m.meta || m.ctrl) return none(ctl);
|
|
219
|
+
const count = layout.regions[ctl.region]?.piles.length ?? 0;
|
|
220
|
+
const index = ctl.piles[ctl.region] ?? 0;
|
|
221
|
+
if ([
|
|
222
|
+
"ArrowLeft",
|
|
223
|
+
"ArrowRight",
|
|
224
|
+
"ArrowUp",
|
|
225
|
+
"ArrowDown",
|
|
226
|
+
"Home",
|
|
227
|
+
"End"
|
|
228
|
+
].includes(m.key)) {
|
|
229
|
+
const spatial = geometricFocus(ctl, layout, m.key);
|
|
230
|
+
if (spatial !== null) return spatial;
|
|
231
|
+
}
|
|
232
|
+
switch (m.key) {
|
|
233
|
+
case "ArrowLeft": return focusAt(ctl, layout, ctl.region, Math.max(0, index - 1));
|
|
234
|
+
case "ArrowRight": return focusAt(ctl, layout, ctl.region, Math.min(count - 1, index + 1));
|
|
235
|
+
case "Home": return focusAt(ctl, layout, ctl.region, 0);
|
|
236
|
+
case "End": return focusAt(ctl, layout, ctl.region, count - 1);
|
|
237
|
+
case "ArrowUp": return deepen(ctl, pile, 1);
|
|
238
|
+
case "ArrowDown": return deepen(ctl, pile, -1);
|
|
239
|
+
case "Enter":
|
|
240
|
+
case " ": return activate(ctl, pile);
|
|
241
|
+
case "Escape": return putBack(ctl, layout);
|
|
242
|
+
}
|
|
243
|
+
if (!options.singleKeys || m.shift) return none(ctl);
|
|
244
|
+
const key = m.key.toLowerCase();
|
|
245
|
+
if (isDigit(key)) {
|
|
246
|
+
const target = layout.regions.flatMap((r, ri) => r.piles.map((p, pi) => ({
|
|
247
|
+
p,
|
|
248
|
+
ri,
|
|
249
|
+
pi
|
|
250
|
+
}))).find(({ p }) => p.key === key);
|
|
251
|
+
if (target === void 0) return none(ctl);
|
|
252
|
+
const moved = focusAt(ctl, layout, target.ri, target.pi);
|
|
253
|
+
if (ctl.held === null) return layout.activateNumbers === true ? {
|
|
254
|
+
...activate(moved.ctl, target.p),
|
|
255
|
+
focus: moved.focus
|
|
256
|
+
} : moved;
|
|
257
|
+
return {
|
|
258
|
+
...drop(moved.ctl, ctl.held, target.p.id, "keyboard"),
|
|
259
|
+
focus: moved.focus
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (layout.keys.includes(key)) {
|
|
263
|
+
if (key === "n" || key === "t" || key === "w") {
|
|
264
|
+
const piles = layout.regions.flatMap((r, ri) => r.piles.map((p, pi) => ({
|
|
265
|
+
p,
|
|
266
|
+
ri,
|
|
267
|
+
pi
|
|
268
|
+
})));
|
|
269
|
+
const available = piles.filter(({ p }) => p.pickable > 0).sort((a, b) => (b.p.position?.row ?? -1) - (a.p.position?.row ?? -1) || (a.p.position?.column ?? a.pi) - (b.p.position?.column ?? b.pi));
|
|
270
|
+
const current = available.findIndex(({ p }) => p.id === pile.id);
|
|
271
|
+
const next = key === "n" ? available[(current + 1) % Math.max(1, available.length)] : piles.find(({ p }) => p.id === (key === "t" ? "turned" : "waste"));
|
|
272
|
+
if (next !== void 0) return focusAt(ctl, layout, next.ri, next.pi);
|
|
273
|
+
return none(ctl);
|
|
274
|
+
}
|
|
275
|
+
const held = ctl.held;
|
|
276
|
+
const out = held === null ? {
|
|
277
|
+
_tag: "Key",
|
|
278
|
+
key,
|
|
279
|
+
pile: pile.id,
|
|
280
|
+
count: clamp(ctl.depth, 1, Math.max(1, pile.pickable))
|
|
281
|
+
} : {
|
|
282
|
+
_tag: "Key",
|
|
283
|
+
key,
|
|
284
|
+
pile: held.from,
|
|
285
|
+
count: held.count
|
|
286
|
+
};
|
|
287
|
+
return {
|
|
288
|
+
ctl: {
|
|
289
|
+
...ctl,
|
|
290
|
+
held: null
|
|
291
|
+
},
|
|
292
|
+
outs: [out],
|
|
293
|
+
focus: null
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return none(ctl);
|
|
297
|
+
};
|
|
298
|
+
/** A click (a press released without dragging) on `pileId` at `depth`. */
|
|
299
|
+
var click = (ctl, layout, pileId, depth) => {
|
|
300
|
+
const found = pileId === null ? null : findPile(layout, pileId);
|
|
301
|
+
if (found === null) return putBack(ctl, layout);
|
|
302
|
+
const { pile } = found;
|
|
303
|
+
const focused = {
|
|
304
|
+
...ctl,
|
|
305
|
+
region: found.region,
|
|
306
|
+
piles: ctl.piles.map((p, i) => i === found.region ? found.index : p)
|
|
307
|
+
};
|
|
308
|
+
const held = ctl.held;
|
|
309
|
+
if (held !== null) {
|
|
310
|
+
if (pile.id !== held.from) return drop(focused, held, pile.id, "click");
|
|
311
|
+
if (depth === held.count) return drop(focused, held, pile.id, "click");
|
|
312
|
+
if (depth >= 1 && depth <= pile.pickable) return pickUp({
|
|
313
|
+
...focused,
|
|
314
|
+
depth
|
|
315
|
+
}, pile, depth, "click");
|
|
316
|
+
return putBack(focused, layout);
|
|
317
|
+
}
|
|
318
|
+
if (pile.action === true) return {
|
|
319
|
+
ctl: focused,
|
|
320
|
+
outs: [{
|
|
321
|
+
_tag: "Activate",
|
|
322
|
+
pile: pile.id
|
|
323
|
+
}],
|
|
324
|
+
focus: null
|
|
325
|
+
};
|
|
326
|
+
if (depth >= 1 && depth <= pile.pickable) return pickUp({
|
|
327
|
+
...focused,
|
|
328
|
+
depth
|
|
329
|
+
}, pile, depth, "click");
|
|
330
|
+
return none(focused);
|
|
331
|
+
};
|
|
332
|
+
/** The controller's reducer. */
|
|
333
|
+
var step = (before, layout, options, m) => {
|
|
334
|
+
const ctl = sync(before, layout);
|
|
335
|
+
switch (m._tag) {
|
|
336
|
+
case "Key": return onKey(ctl, layout, options, m);
|
|
337
|
+
case "Focus": {
|
|
338
|
+
const found = findPile(layout, m.pile);
|
|
339
|
+
if (found === null) return none(ctl);
|
|
340
|
+
if (found.region === ctl.region && found.index === ctl.piles[found.region]) return none(ctl);
|
|
341
|
+
const piles = ctl.piles.slice();
|
|
342
|
+
piles[found.region] = found.index;
|
|
343
|
+
return none({
|
|
344
|
+
...ctl,
|
|
345
|
+
region: found.region,
|
|
346
|
+
piles,
|
|
347
|
+
depth: 1
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
case "Down": {
|
|
351
|
+
const stale = ctl.press?.dragging === true;
|
|
352
|
+
return none({
|
|
353
|
+
...ctl,
|
|
354
|
+
held: stale ? null : ctl.held,
|
|
355
|
+
press: {
|
|
356
|
+
pile: m.pile,
|
|
357
|
+
depth: m.depth,
|
|
358
|
+
x: m.x,
|
|
359
|
+
y: m.y,
|
|
360
|
+
dragging: false,
|
|
361
|
+
dx: 0,
|
|
362
|
+
dy: 0,
|
|
363
|
+
over: null
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
case "Move": {
|
|
368
|
+
const press = ctl.press;
|
|
369
|
+
if (press === null || press.pile === null) return none(ctl);
|
|
370
|
+
const dx = m.x - press.x;
|
|
371
|
+
const dy = m.y - press.y;
|
|
372
|
+
if (press.dragging) return none({
|
|
373
|
+
...ctl,
|
|
374
|
+
press: {
|
|
375
|
+
...press,
|
|
376
|
+
dx,
|
|
377
|
+
dy,
|
|
378
|
+
over: m.over
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
if (Math.sqrt(dx * dx + dy * dy) < 6) return none(ctl);
|
|
382
|
+
const pile = findPile(layout, press.pile)?.pile;
|
|
383
|
+
const top = pile?.cards[pile.cards.length - press.depth];
|
|
384
|
+
if (pile === void 0 || press.depth < 1 || press.depth > pile.pickable || !isFaceCard(top)) return none({
|
|
385
|
+
...ctl,
|
|
386
|
+
press: null
|
|
387
|
+
});
|
|
388
|
+
return none({
|
|
389
|
+
...ctl,
|
|
390
|
+
held: {
|
|
391
|
+
from: pile.id,
|
|
392
|
+
count: press.depth,
|
|
393
|
+
via: "drag"
|
|
394
|
+
},
|
|
395
|
+
press: {
|
|
396
|
+
...press,
|
|
397
|
+
dragging: true,
|
|
398
|
+
dx,
|
|
399
|
+
dy,
|
|
400
|
+
over: m.over
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
case "Up": {
|
|
405
|
+
const press = ctl.press;
|
|
406
|
+
if (press === null) return none(ctl);
|
|
407
|
+
const released = {
|
|
408
|
+
...ctl,
|
|
409
|
+
press: null
|
|
410
|
+
};
|
|
411
|
+
if (!press.dragging) return click(released, layout, press.pile, press.depth);
|
|
412
|
+
const held = ctl.held;
|
|
413
|
+
const to = m.over;
|
|
414
|
+
if (held === null || to === null || to === held.from) return none({
|
|
415
|
+
...released,
|
|
416
|
+
held: null
|
|
417
|
+
});
|
|
418
|
+
return {
|
|
419
|
+
ctl: {
|
|
420
|
+
...released,
|
|
421
|
+
held: null,
|
|
422
|
+
settle: true
|
|
423
|
+
},
|
|
424
|
+
outs: [{
|
|
425
|
+
_tag: "Move",
|
|
426
|
+
from: held.from,
|
|
427
|
+
to,
|
|
428
|
+
count: held.count,
|
|
429
|
+
via: "drag"
|
|
430
|
+
}],
|
|
431
|
+
focus: null
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
case "Cancel": {
|
|
435
|
+
const dragging = ctl.press?.dragging === true;
|
|
436
|
+
return none({
|
|
437
|
+
...ctl,
|
|
438
|
+
press: null,
|
|
439
|
+
held: dragging ? null : ctl.held
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/** True when the table should render this layout without card flights. */
|
|
445
|
+
var isQuiet = (ctl, layout, options) => options.calm || ctl.quiet === layout.version;
|
|
446
|
+
//#endregion
|
|
447
|
+
export { DRAG_THRESHOLD, describe, focusedPile, initialCtl, isQuiet, spoken, step, sync };
|
|
448
|
+
|
|
449
|
+
//# sourceMappingURL=controller.js.map
|