@agencecinq/snake 1.0.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/README.md +172 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/game.d.ts +31 -0
- package/dist/game.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +312 -0
- package/dist/keyboard.d.ts +13 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/loop.d.ts +15 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/renderer.d.ts +16 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/snake.d.ts +30 -0
- package/dist/snake.d.ts.map +1 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/@agencecinq/snake)
|
|
2
|
+
[](https://www.npmjs.com/package/@agencecinq/snake)
|
|
3
|
+
|
|
4
|
+
# @agencecinq/snake
|
|
5
|
+
|
|
6
|
+
> Canvas Snake as a lightweight Web Component.
|
|
7
|
+
|
|
8
|
+
`<cinq-snake>` runs the game loop and draws square cells into a consumer-owned
|
|
9
|
+
`<canvas>`. Score, overlay, replay UI, layout, and colors stay in your page.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @agencecinq/snake
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Web Component (`<cinq-snake>`)
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<cinq-snake cols="21" rows="15">
|
|
23
|
+
<p>Score <span data-score aria-live="polite">0</span></p>
|
|
24
|
+
<div class="snake-stage">
|
|
25
|
+
<canvas tabindex="0" aria-label="Snake. Arrow keys to move."></canvas>
|
|
26
|
+
<div data-overlay hidden>
|
|
27
|
+
<p data-status>Game over</p>
|
|
28
|
+
<button type="button" data-replay>Play again</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</cinq-snake>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import "@agencecinq/snake";
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Importing `@agencecinq/snake` registers the Web Component automatically.
|
|
39
|
+
No manual `init()` call required.
|
|
40
|
+
|
|
41
|
+
> **HTML is the source of truth.** The component will not auto-set `role`,
|
|
42
|
+
> auto-migrate attributes, or warn about missing labels. Use an a11y linter
|
|
43
|
+
> (axe-core, Lighthouse) to catch invalid markup.
|
|
44
|
+
|
|
45
|
+
The `[data-score]` span, overlay, and replay button are yours. The host does
|
|
46
|
+
not query them or write to them.
|
|
47
|
+
|
|
48
|
+
### Required markup
|
|
49
|
+
|
|
50
|
+
| Attribute / element | Required | Role |
|
|
51
|
+
| ------------------- | -------- | ---- |
|
|
52
|
+
| `<cinq-snake>` | **Yes** | Host. Observes `cols` and `rows`. |
|
|
53
|
+
| `<canvas>` | **Yes** | Playfield. One canvas per host. Give it `tabindex="0"` so arrow keys reach it. |
|
|
54
|
+
|
|
55
|
+
### Layout and color
|
|
56
|
+
|
|
57
|
+
Size the canvas yourself. Use `width: 100%` and `aspect-ratio: 21 / 15`
|
|
58
|
+
(or `cols / rows`) for square tiles. After the canvas box changes, call
|
|
59
|
+
`sync()` (a `ResizeObserver` on the canvas is enough).
|
|
60
|
+
|
|
61
|
+
Ink follows the canvas CSS `color` (inherited). `background-color` and any
|
|
62
|
+
frame (`border`, `outline`, etc.) are yours. The component clears the bitmap
|
|
63
|
+
each frame so that fill shows through. If CSS `color` is missing, the package
|
|
64
|
+
falls back to `#2a2a2a`.
|
|
65
|
+
|
|
66
|
+
The component does not ship CSS.
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
cinq-snake {
|
|
70
|
+
display: block;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cinq-snake canvas {
|
|
74
|
+
display: block;
|
|
75
|
+
width: 100%;
|
|
76
|
+
height: auto;
|
|
77
|
+
aspect-ratio: 21 / 15;
|
|
78
|
+
touch-action: none;
|
|
79
|
+
image-rendering: pixelated;
|
|
80
|
+
background-color: #c5d0b8;
|
|
81
|
+
color: #2a2a2a;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
cinq-snake canvas:focus-visible {
|
|
85
|
+
outline: 2px solid currentColor;
|
|
86
|
+
outline-offset: 4px;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Attributes
|
|
91
|
+
|
|
92
|
+
| Attribute | Default | Description |
|
|
93
|
+
| --------- | ------- | ----------- |
|
|
94
|
+
| `cols` | `21` | Horizontal cell count. Observed. Resets the board when it changes. |
|
|
95
|
+
| `rows` | `15` | Vertical cell count. Observed. Resets the board when it changes. |
|
|
96
|
+
|
|
97
|
+
Changing `cols` or `rows` also dispatches `snake:replay`.
|
|
98
|
+
|
|
99
|
+
### API
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
const $host = document.querySelector("cinq-snake");
|
|
103
|
+
|
|
104
|
+
$host.init();
|
|
105
|
+
$host.sync();
|
|
106
|
+
$host.replay();
|
|
107
|
+
$host.destroy();
|
|
108
|
+
$host.score;
|
|
109
|
+
$host.cols;
|
|
110
|
+
$host.rows;
|
|
111
|
+
$host.$canvas;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Call `destroy()` before `init()` if the host is already bound and you mutated
|
|
115
|
+
the light DOM.
|
|
116
|
+
|
|
117
|
+
### Keyboard
|
|
118
|
+
|
|
119
|
+
| Key | Function |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| Arrow keys | Turn. 180-degree reverses are ignored. The first arrow starts the game. |
|
|
122
|
+
| Enter or Space | After a collision, start again (when the canvas still holds focus). |
|
|
123
|
+
|
|
124
|
+
On a touch screen, swipe on the board to turn.
|
|
125
|
+
|
|
126
|
+
### Events
|
|
127
|
+
|
|
128
|
+
Dispatched on the host `<cinq-snake>` (bubble, not cancelable). Prefer
|
|
129
|
+
constants from `@agencecinq/utils`:
|
|
130
|
+
|
|
131
|
+
| Event | Constant | Detail | When |
|
|
132
|
+
| ----- | -------- | ------ | ---- |
|
|
133
|
+
| `snake:eat` | `SNAKE_EAT` | `{ score }` | After food |
|
|
134
|
+
| `snake:over` | `SNAKE_OVER` | `{ score }` | After a wall or self hit |
|
|
135
|
+
| `snake:replay` | `SNAKE_REPLAY` | `{ score }` | After `replay()`, or after `cols` / `rows` change |
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { EVENTS } from "@agencecinq/utils";
|
|
139
|
+
|
|
140
|
+
const $host = document.querySelector("cinq-snake");
|
|
141
|
+
const $score = $host.querySelector("[data-score]");
|
|
142
|
+
const $overlay = $host.querySelector("[data-overlay]");
|
|
143
|
+
const $replay = $host.querySelector("[data-replay]");
|
|
144
|
+
|
|
145
|
+
$host.addEventListener(EVENTS.SNAKE_EAT, (event) => {
|
|
146
|
+
$score.textContent = String(event.detail.score);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
$host.addEventListener(EVENTS.SNAKE_OVER, () => {
|
|
150
|
+
$overlay.hidden = false;
|
|
151
|
+
$replay.focus();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
$host.addEventListener(EVENTS.SNAKE_REPLAY, (event) => {
|
|
155
|
+
$overlay.hidden = true;
|
|
156
|
+
$score.textContent = String(event.detail.score);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
$replay.addEventListener("click", () => {
|
|
160
|
+
$host.replay();
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Build setup
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm -C packages/snake build
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Acknowledgments
|
|
171
|
+
|
|
172
|
+
See the [interactive docs](https://agencecinq.github.io/ui/components/snake/) for live examples.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback ink when the canvas has no usable `color`.
|
|
3
|
+
*/
|
|
4
|
+
export declare const color = "#2a2a2a";
|
|
5
|
+
/** Default `cols` when the host has no `cols` attribute. */
|
|
6
|
+
export declare const COLS = 21;
|
|
7
|
+
/** Default `rows` when the host has no `rows` attribute. */
|
|
8
|
+
export declare const ROWS = 15;
|
|
9
|
+
/** Base interval between snake steps, in milliseconds. */
|
|
10
|
+
export declare const STEP_MS = 130;
|
|
11
|
+
/** Fastest interval after score-based speed-up, in milliseconds. */
|
|
12
|
+
export declare const STEP_MS_MIN = 70;
|
|
13
|
+
/** Starting body length, including the head. Faces right from the board center. */
|
|
14
|
+
export declare const INITIAL_LENGTH = 3;
|
|
15
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,KAAK,YAAY,CAAC;AAE/B,4DAA4D;AAC5D,eAAO,MAAM,IAAI,KAAK,CAAC;AAEvB,4DAA4D;AAC5D,eAAO,MAAM,IAAI,KAAK,CAAC;AAEvB,0DAA0D;AAC1D,eAAO,MAAM,OAAO,MAAM,CAAC;AAE3B,oEAAoE;AACpE,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,mFAAmF;AACnF,eAAO,MAAM,cAAc,IAAI,CAAC"}
|
package/dist/game.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Direction, Point, TickResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Grid state: snake, food, score, collisions.
|
|
4
|
+
* No DOM, no canvas, no timers. The host asks for a tick on each step.
|
|
5
|
+
*/
|
|
6
|
+
export default class Game {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(cols?: number, rows?: number);
|
|
9
|
+
get cols(): number;
|
|
10
|
+
get rows(): number;
|
|
11
|
+
get snake(): readonly Point[];
|
|
12
|
+
get food(): Point;
|
|
13
|
+
get score(): number;
|
|
14
|
+
get alive(): boolean;
|
|
15
|
+
get started(): boolean;
|
|
16
|
+
/** Change the grid size and reset the board. */
|
|
17
|
+
setSize(cols: number, rows: number): void;
|
|
18
|
+
/** Place a centered snake, spawn food, clear score. Waits for the first turn. */
|
|
19
|
+
reset(): void;
|
|
20
|
+
/**
|
|
21
|
+
* Queue the next heading. 180-degree turns are ignored so the snake cannot
|
|
22
|
+
* fold onto itself between two ticks. The first accepted key starts movement.
|
|
23
|
+
*/
|
|
24
|
+
setDirection(next: Direction): void;
|
|
25
|
+
/**
|
|
26
|
+
* Advance one cell. Returns `idle` until the first direction, `dead` on a
|
|
27
|
+
* wall or self hit, `eat` when the head lands on food, otherwise `move`.
|
|
28
|
+
*/
|
|
29
|
+
tick(): TickResult;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=game.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"game.d.ts","sourceRoot":"","sources":["../src/game.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAgB/D;;;GAGG;AACH,MAAM,CAAC,OAAO,OAAO,IAAI;;gBAWX,IAAI,SAAO,EAAE,IAAI,SAAO;IAMpC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,KAAK,IAAI,SAAS,KAAK,EAAE,CAE5B;IAED,IAAI,IAAI,IAAI,KAAK,CAEhB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,KAAK,IAAI,OAAO,CAEnB;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,gDAAgD;IAChD,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAMzC,iFAAiF;IACjF,KAAK,IAAI,IAAI;IAsBb;;;OAGG;IACH,YAAY,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI;IAYnC;;;OAGG;IACH,IAAI,IAAI,UAAU;CAiFnB"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,OAAO,EAAE,KAAK,EAAE,CAAC;AACjB,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACzD,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { EVENTS as e, dispatchEvent as t, parseNumber as n } from "@agencecinq/utils";
|
|
2
|
+
//#region src/config.ts
|
|
3
|
+
var r = "#2a2a2a", i = 21, a = 15, o = 130, s = {
|
|
4
|
+
up: "down",
|
|
5
|
+
down: "up",
|
|
6
|
+
left: "right",
|
|
7
|
+
right: "left"
|
|
8
|
+
}, c = {
|
|
9
|
+
up: {
|
|
10
|
+
x: 0,
|
|
11
|
+
y: -1
|
|
12
|
+
},
|
|
13
|
+
down: {
|
|
14
|
+
x: 0,
|
|
15
|
+
y: 1
|
|
16
|
+
},
|
|
17
|
+
left: {
|
|
18
|
+
x: -1,
|
|
19
|
+
y: 0
|
|
20
|
+
},
|
|
21
|
+
right: {
|
|
22
|
+
x: 1,
|
|
23
|
+
y: 0
|
|
24
|
+
}
|
|
25
|
+
}, l = class {
|
|
26
|
+
#e;
|
|
27
|
+
#t;
|
|
28
|
+
#n = [];
|
|
29
|
+
#r = "right";
|
|
30
|
+
#i = "right";
|
|
31
|
+
#a = {
|
|
32
|
+
x: 0,
|
|
33
|
+
y: 0
|
|
34
|
+
};
|
|
35
|
+
#o = 0;
|
|
36
|
+
#s = !0;
|
|
37
|
+
#c = !1;
|
|
38
|
+
constructor(e = 21, t = 15) {
|
|
39
|
+
this.#e = Math.max(1, e), this.#t = Math.max(1, t), this.reset();
|
|
40
|
+
}
|
|
41
|
+
get cols() {
|
|
42
|
+
return this.#e;
|
|
43
|
+
}
|
|
44
|
+
get rows() {
|
|
45
|
+
return this.#t;
|
|
46
|
+
}
|
|
47
|
+
get snake() {
|
|
48
|
+
return this.#n;
|
|
49
|
+
}
|
|
50
|
+
get food() {
|
|
51
|
+
return this.#a;
|
|
52
|
+
}
|
|
53
|
+
get score() {
|
|
54
|
+
return this.#o;
|
|
55
|
+
}
|
|
56
|
+
get alive() {
|
|
57
|
+
return this.#s;
|
|
58
|
+
}
|
|
59
|
+
get started() {
|
|
60
|
+
return this.#c;
|
|
61
|
+
}
|
|
62
|
+
setSize(e, t) {
|
|
63
|
+
this.#e = Math.max(1, Math.floor(e)), this.#t = Math.max(1, Math.floor(t)), this.reset();
|
|
64
|
+
}
|
|
65
|
+
reset() {
|
|
66
|
+
let e = Math.min(3, this.#e), t = Math.min(this.#t - 1, Math.floor(this.#t / 2)), n = Math.min(this.#e - 1, Math.max(e - 1, Math.floor(this.#e / 2))), r = [];
|
|
67
|
+
for (let i = 0; i < e; i += 1) r.push({
|
|
68
|
+
x: n - i,
|
|
69
|
+
y: t
|
|
70
|
+
});
|
|
71
|
+
this.#n = r, this.#r = "right", this.#i = "right", this.#o = 0, this.#s = !0, this.#c = !1, this.#d();
|
|
72
|
+
}
|
|
73
|
+
setDirection(e) {
|
|
74
|
+
e !== s[this.#r] && (this.#i = e, !this.#c && this.#s && (this.#c = !0));
|
|
75
|
+
}
|
|
76
|
+
tick() {
|
|
77
|
+
if (!this.#s) return "dead";
|
|
78
|
+
if (!this.#c) return "idle";
|
|
79
|
+
this.#r = this.#i;
|
|
80
|
+
let { x: e, y: t } = c[this.#r], n = this.#n[0], r = {
|
|
81
|
+
x: n.x + e,
|
|
82
|
+
y: n.y + t
|
|
83
|
+
};
|
|
84
|
+
if (this.#l(r)) return this.#s = !1, "dead";
|
|
85
|
+
let i = r.x === this.#a.x && r.y === this.#a.y;
|
|
86
|
+
return this.#u(r, i) ? (this.#s = !1, "dead") : (this.#n.unshift(r), i ? (this.#o += 1, this.#d(), "eat") : (this.#n.pop(), "move"));
|
|
87
|
+
}
|
|
88
|
+
#l({ x: e, y: t }) {
|
|
89
|
+
return e < 0 || t < 0 || e >= this.#e || t >= this.#t;
|
|
90
|
+
}
|
|
91
|
+
#u(e, t) {
|
|
92
|
+
let n = t ? this.#n.length : this.#n.length - 1;
|
|
93
|
+
for (let t = 0; t < n; t += 1) {
|
|
94
|
+
let n = this.#n[t];
|
|
95
|
+
if (n.x === e.x && n.y === e.y) return !0;
|
|
96
|
+
}
|
|
97
|
+
return !1;
|
|
98
|
+
}
|
|
99
|
+
#d() {
|
|
100
|
+
let e = new Set(this.#n.map((e) => `${e.x},${e.y}`)), t = [];
|
|
101
|
+
for (let n = 0; n < this.#t; n += 1) for (let r = 0; r < this.#e; r += 1) e.has(`${r},${n}`) || t.push({
|
|
102
|
+
x: r,
|
|
103
|
+
y: n
|
|
104
|
+
});
|
|
105
|
+
t.length !== 0 && (this.#a = t[Math.floor(Math.random() * t.length)]);
|
|
106
|
+
}
|
|
107
|
+
}, u = {
|
|
108
|
+
ArrowUp: "up",
|
|
109
|
+
ArrowDown: "down",
|
|
110
|
+
ArrowLeft: "left",
|
|
111
|
+
ArrowRight: "right"
|
|
112
|
+
}, d = 24, f = class {
|
|
113
|
+
#e;
|
|
114
|
+
#t = null;
|
|
115
|
+
constructor(e) {
|
|
116
|
+
this.#e = e;
|
|
117
|
+
}
|
|
118
|
+
handle = (e) => {
|
|
119
|
+
let t = u[e.key];
|
|
120
|
+
t && (e.preventDefault(), this.#e(t));
|
|
121
|
+
};
|
|
122
|
+
handlePointerDown = (e) => {
|
|
123
|
+
this.#t = {
|
|
124
|
+
x: e.clientX,
|
|
125
|
+
y: e.clientY
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
handlePointerUp = (e) => {
|
|
129
|
+
let t = this.#t;
|
|
130
|
+
if (this.#t = null, !t) return;
|
|
131
|
+
let { clientX: n, clientY: r } = e, i = n - t.x, a = r - t.y;
|
|
132
|
+
if (!(Math.hypot(i, a) < d)) {
|
|
133
|
+
if (Math.abs(i) > Math.abs(a)) {
|
|
134
|
+
this.#e(i > 0 ? "right" : "left");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.#e(a > 0 ? "down" : "up");
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
handlePointerCancel = () => {
|
|
141
|
+
this.#t = null;
|
|
142
|
+
};
|
|
143
|
+
}, p = class {
|
|
144
|
+
#e;
|
|
145
|
+
#t;
|
|
146
|
+
#n;
|
|
147
|
+
#r = 0;
|
|
148
|
+
#i = 0;
|
|
149
|
+
#a = 0;
|
|
150
|
+
#o = !1;
|
|
151
|
+
constructor(e, t, n) {
|
|
152
|
+
this.#e = e, this.#t = t, this.#n = n;
|
|
153
|
+
}
|
|
154
|
+
get running() {
|
|
155
|
+
return this.#o;
|
|
156
|
+
}
|
|
157
|
+
set stepMs(e) {
|
|
158
|
+
this.#e = e;
|
|
159
|
+
}
|
|
160
|
+
hold() {
|
|
161
|
+
this.#i = 0, this.#a = 0;
|
|
162
|
+
}
|
|
163
|
+
start() {
|
|
164
|
+
this.#o || (this.#o = !0, this.#i = 0, this.#a = 0, this.#r = requestAnimationFrame(this.#s));
|
|
165
|
+
}
|
|
166
|
+
stop() {
|
|
167
|
+
this.#o = !1, this.#r && (cancelAnimationFrame(this.#r), this.#r = 0, this.#i = 0, this.#a = 0);
|
|
168
|
+
}
|
|
169
|
+
#s = (e) => {
|
|
170
|
+
if (!this.#o) return;
|
|
171
|
+
if (this.#r = requestAnimationFrame(this.#s), this.#i === 0) {
|
|
172
|
+
this.#i = e, this.#n();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let t = Math.min(e - this.#i, this.#e * 3);
|
|
176
|
+
for (this.#i = e, this.#a += t; this.#o && this.#a >= this.#e;) this.#t(), this.#a -= this.#e;
|
|
177
|
+
this.#o && this.#n();
|
|
178
|
+
};
|
|
179
|
+
}, m = class {
|
|
180
|
+
#e;
|
|
181
|
+
#t;
|
|
182
|
+
constructor(e, t) {
|
|
183
|
+
this.#e = e, this.#t = t;
|
|
184
|
+
}
|
|
185
|
+
resize() {
|
|
186
|
+
let { clientWidth: e, clientHeight: t } = this.#e;
|
|
187
|
+
if (e === 0 || t === 0) return;
|
|
188
|
+
let n = window.devicePixelRatio || 1, r = Math.round(e * n), i = Math.round(t * n);
|
|
189
|
+
(this.#e.width !== r || this.#e.height !== i) && (this.#e.width = r, this.#e.height = i), this.#t.setTransform(n, 0, 0, n, 0, 0);
|
|
190
|
+
}
|
|
191
|
+
draw(e, t) {
|
|
192
|
+
let n = this.#n(e.cols, e.rows);
|
|
193
|
+
if (!n) return;
|
|
194
|
+
let { width: r, height: i } = n, a = this.#t;
|
|
195
|
+
a.imageSmoothingEnabled = !1, a.clearRect(0, 0, r, i);
|
|
196
|
+
for (let r of e.snake) this.#r(r.x, r.y, n, t, .12);
|
|
197
|
+
this.#r(e.food.x, e.food.y, n, t, .32);
|
|
198
|
+
}
|
|
199
|
+
#n(e, t) {
|
|
200
|
+
let { clientWidth: n, clientHeight: r } = this.#e;
|
|
201
|
+
return n === 0 || r === 0 ? null : {
|
|
202
|
+
width: n,
|
|
203
|
+
height: r,
|
|
204
|
+
cols: e,
|
|
205
|
+
rows: t
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
#r(e, t, n, r, i) {
|
|
209
|
+
let { width: a, height: o, cols: s, rows: c } = n, l = e * a / s, u = t * o / c, d = (e + 1) * a / s - l, f = (t + 1) * o / c - u, p = Math.max(1, Math.round(Math.min(d, f) * i)), m = d - p * 2, h = f - p * 2;
|
|
210
|
+
m <= 0 || h <= 0 || (this.#t.fillStyle = r, this.#t.fillRect(l + p, u + p, m, h));
|
|
211
|
+
}
|
|
212
|
+
}, h = class extends HTMLElement {
|
|
213
|
+
static observedAttributes = ["cols", "rows"];
|
|
214
|
+
$canvas = null;
|
|
215
|
+
#e = new l();
|
|
216
|
+
#t = null;
|
|
217
|
+
#n = null;
|
|
218
|
+
#r = null;
|
|
219
|
+
connectedCallback() {
|
|
220
|
+
this.init();
|
|
221
|
+
}
|
|
222
|
+
disconnectedCallback() {
|
|
223
|
+
this.destroy(), this.$canvas = null, this.#n = null, this.#r = null;
|
|
224
|
+
}
|
|
225
|
+
attributeChangedCallback(e, t, n) {
|
|
226
|
+
(e === "cols" || e === "rows") && this.#t && t !== n && this.#a(!0);
|
|
227
|
+
}
|
|
228
|
+
init() {
|
|
229
|
+
if (this.#t) return;
|
|
230
|
+
if (this.$canvas = this.querySelector("canvas"), !this.$canvas) throw Error("Snake must contain a canvas element");
|
|
231
|
+
let e = this.$canvas.getContext("2d");
|
|
232
|
+
if (!e) throw Error("Snake could not get a 2d canvas context");
|
|
233
|
+
this.#n = new m(this.$canvas, e), this.#r = new f(this.#o), this.#t = new p(130, this.#s, this.#c), this.addEventListener("keydown", this.#f), this.$canvas.addEventListener("pointerdown", this.#p), this.$canvas.addEventListener("pointerup", this.#r.handlePointerUp), this.$canvas.addEventListener("pointercancel", this.#r.handlePointerCancel), this.#a(!1), this.sync(), this.#t.start();
|
|
234
|
+
}
|
|
235
|
+
destroy() {
|
|
236
|
+
if (!this.#t) return;
|
|
237
|
+
let { $canvas: e } = this, t = this.#r;
|
|
238
|
+
this.#t.stop(), this.#t = null, this.removeEventListener("keydown", this.#f), e?.removeEventListener("pointerdown", this.#p), e && t && (e.removeEventListener("pointerup", t.handlePointerUp), e.removeEventListener("pointercancel", t.handlePointerCancel)), this.#n = null, this.#r = null;
|
|
239
|
+
}
|
|
240
|
+
sync() {
|
|
241
|
+
this.#n?.resize(), this.#c();
|
|
242
|
+
}
|
|
243
|
+
replay() {
|
|
244
|
+
this.#e.reset(), this.#u(), this.sync(), this.#t?.start(), this.$canvas?.focus(), t(this, e.SNAKE_REPLAY, { score: this.#e.score }, { cancelable: !1 });
|
|
245
|
+
}
|
|
246
|
+
get score() {
|
|
247
|
+
return this.#e.score;
|
|
248
|
+
}
|
|
249
|
+
get cols() {
|
|
250
|
+
return this.#e.cols;
|
|
251
|
+
}
|
|
252
|
+
get rows() {
|
|
253
|
+
return this.#e.rows;
|
|
254
|
+
}
|
|
255
|
+
#i() {
|
|
256
|
+
return {
|
|
257
|
+
cols: Math.max(1, Math.floor(n(this.getAttribute("cols"), 21))),
|
|
258
|
+
rows: Math.max(1, Math.floor(n(this.getAttribute("rows"), 15)))
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
#a(n) {
|
|
262
|
+
let { cols: r, rows: i } = this.#i();
|
|
263
|
+
(r !== this.#e.cols || i !== this.#e.rows) && (this.#e.setSize(r, i), this.#u(), this.sync(), this.#t?.start(), n && t(this, e.SNAKE_REPLAY, { score: this.#e.score }, { cancelable: !1 }));
|
|
264
|
+
}
|
|
265
|
+
#o = (e) => {
|
|
266
|
+
if (!this.#e.alive) return;
|
|
267
|
+
let t = !this.#e.started;
|
|
268
|
+
this.#e.setDirection(e), t && this.#e.started && (this.#s(), this.#t?.hold());
|
|
269
|
+
};
|
|
270
|
+
#s = () => {
|
|
271
|
+
let n = this.#e.tick();
|
|
272
|
+
if (n === "eat") {
|
|
273
|
+
this.#u(), t(this, e.SNAKE_EAT, { score: this.#e.score }, { cancelable: !1 });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
n === "dead" && this.#l();
|
|
277
|
+
};
|
|
278
|
+
#c = () => {
|
|
279
|
+
this.#n && this.$canvas && this.#n.draw({
|
|
280
|
+
snake: this.#e.snake,
|
|
281
|
+
food: this.#e.food,
|
|
282
|
+
cols: this.#e.cols,
|
|
283
|
+
rows: this.#e.rows
|
|
284
|
+
}, this.#d());
|
|
285
|
+
};
|
|
286
|
+
#l() {
|
|
287
|
+
this.#t?.stop(), this.#c(), t(this, e.SNAKE_OVER, { score: this.#e.score }, { cancelable: !1 });
|
|
288
|
+
}
|
|
289
|
+
#u() {
|
|
290
|
+
this.#t && (this.#t.stepMs = Math.max(70, 130 - this.#e.score * 5));
|
|
291
|
+
}
|
|
292
|
+
#d() {
|
|
293
|
+
let { $canvas: e } = this;
|
|
294
|
+
return e ? getComputedStyle(e).color || "#2a2a2a" : r;
|
|
295
|
+
}
|
|
296
|
+
#f = (e) => {
|
|
297
|
+
let { key: t, target: n } = e;
|
|
298
|
+
if (!this.#e.alive) {
|
|
299
|
+
if (n instanceof Element && n.closest("button, a, input, textarea, select")) return;
|
|
300
|
+
(t === "Enter" || t === " ") && (e.preventDefault(), this.replay());
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.#r?.handle(e);
|
|
304
|
+
};
|
|
305
|
+
#p = (e) => {
|
|
306
|
+
let { $canvas: t } = this;
|
|
307
|
+
t && (t.focus(), t.setPointerCapture(e.pointerId), this.#r?.handlePointerDown(e));
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
customElements.get("cinq-snake") || customElements.define("cinq-snake", h);
|
|
311
|
+
//#endregion
|
|
312
|
+
export { i as COLS, a as ROWS, o as STEP_MS, h as Snake, r as color };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Direction } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Arrow keys (and optional swipe) to a heading. The host owns game rules.
|
|
4
|
+
*/
|
|
5
|
+
export default class Keyboard {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(onDirection: (direction: Direction) => void);
|
|
8
|
+
handle: (event: KeyboardEvent) => void;
|
|
9
|
+
handlePointerDown: (event: PointerEvent) => void;
|
|
10
|
+
handlePointerUp: (event: PointerEvent) => void;
|
|
11
|
+
handlePointerCancel: () => void;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=keyboard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keyboard.d.ts","sourceRoot":"","sources":["../src/keyboard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAW5C;;GAEG;AACH,MAAM,CAAC,OAAO,OAAO,QAAQ;;gBAIf,WAAW,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI;IAIvD,MAAM,GAAI,OAAO,aAAa,KAAG,IAAI,CAUnC;IAEF,iBAAiB,GAAI,OAAO,YAAY,KAAG,IAAI,CAE7C;IAEF,eAAe,GAAI,OAAO,YAAY,KAAG,IAAI,CAsB3C;IAEF,mBAAmB,QAAO,IAAI,CAE5B;CACH"}
|
package/dist/loop.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-timestep game loop driven only by `requestAnimationFrame`.
|
|
3
|
+
* Simulation steps run at `stepMs`. Drawing runs once per animation frame.
|
|
4
|
+
*/
|
|
5
|
+
export default class Loop {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(stepMs: number, onTick: () => void, onFrame: () => void);
|
|
8
|
+
get running(): boolean;
|
|
9
|
+
set stepMs(value: number);
|
|
10
|
+
/** Drop leftover time so the next step waits a full interval. */
|
|
11
|
+
hold(): void;
|
|
12
|
+
start(): void;
|
|
13
|
+
stop(): void;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=loop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../src/loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,OAAO,OAAO,IAAI;;gBASX,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,IAAI;IAMnE,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,MAAM,CAAC,KAAK,EAAE,MAAM,EAEvB;IAED,iEAAiE;IACjE,IAAI,IAAI,IAAI;IAKZ,KAAK,IAAI,IAAI;IAWb,IAAI,IAAI,IAAI;CAyCb"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DrawState } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Canvas 2D painter. The grid always covers the canvas box. Cell size is
|
|
4
|
+
* `width / cols` by `height / rows`, so cells stretch with the box.
|
|
5
|
+
*/
|
|
6
|
+
export default class Renderer {
|
|
7
|
+
#private;
|
|
8
|
+
constructor($canvas: HTMLCanvasElement, context: CanvasRenderingContext2D);
|
|
9
|
+
/**
|
|
10
|
+
* Match the bitmap to the CSS size.
|
|
11
|
+
* Call after layout changes, and before the first draw.
|
|
12
|
+
*/
|
|
13
|
+
resize(): void;
|
|
14
|
+
draw(state: DrawState, color: string): void;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAS5C;;;GAGG;AACH,MAAM,CAAC,OAAO,OAAO,QAAQ;;gBAIf,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,wBAAwB;IAKzE;;;OAGG;IACH,MAAM,IAAI,IAAI;IAsBd,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;CA4D5C"}
|
package/dist/snake.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Snake host. Markup in light DOM is the source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Required: a `<canvas>`. Ink follows the canvas CSS `color` (inherited).
|
|
5
|
+
* The playfield fill and any frame are consumer CSS on the canvas.
|
|
6
|
+
* `cols` and `rows` (defaults {@link COLS} / {@link ROWS}). Changing them
|
|
7
|
+
* resets the board. Score, overlay, and replay UI stay in the consumer.
|
|
8
|
+
* Listen for `snake:eat`, `snake:over`, and `snake:replay`, and call
|
|
9
|
+
* {@link replay}.
|
|
10
|
+
*/
|
|
11
|
+
export declare class Snake extends HTMLElement {
|
|
12
|
+
#private;
|
|
13
|
+
static observedAttributes: string[];
|
|
14
|
+
$canvas: HTMLCanvasElement | null;
|
|
15
|
+
connectedCallback(): void;
|
|
16
|
+
disconnectedCallback(): void;
|
|
17
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
18
|
+
/** Bind markup + listeners. Call {@link destroy} first if already bound. */
|
|
19
|
+
init(): void;
|
|
20
|
+
/** Detaches listeners and stops the animation loop. */
|
|
21
|
+
destroy(): void;
|
|
22
|
+
/** Re-measure the canvas bitmap. Call after layout changes. */
|
|
23
|
+
sync(): void;
|
|
24
|
+
/** Reset the board and run the loop again. */
|
|
25
|
+
replay(): void;
|
|
26
|
+
get score(): number;
|
|
27
|
+
get cols(): number;
|
|
28
|
+
get rows(): number;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=snake.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snake.d.ts","sourceRoot":"","sources":["../src/snake.ts"],"names":[],"mappings":"AAQA;;;;;;;;;GASG;AACH,qBAAa,KAAM,SAAQ,WAAW;;IACpC,MAAM,CAAC,kBAAkB,WAAoB;IAE7C,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAQ;IAOzC,iBAAiB,IAAI,IAAI;IAIzB,oBAAoB,IAAI,IAAI;IAO5B,wBAAwB,CACtB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,GACtB,IAAI;IAYP,4EAA4E;IAC5E,IAAI,IAAI,IAAI;IAkCZ,uDAAuD;IACvD,OAAO,IAAI,IAAI;IA0Bf,+DAA+D;IAC/D,IAAI,IAAI,IAAI;IAKZ,8CAA8C;IAC9C,MAAM,IAAI,IAAI;IAcd,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;CA0JF"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Direction = "up" | "down" | "left" | "right";
|
|
2
|
+
export type TickResult = "idle" | "move" | "eat" | "dead";
|
|
3
|
+
export interface Point {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
export interface Detail {
|
|
8
|
+
score: number;
|
|
9
|
+
}
|
|
10
|
+
/** Snapshot the renderer needs. The host passes this, not the Game class. */
|
|
11
|
+
export interface DrawState {
|
|
12
|
+
snake: readonly Point[];
|
|
13
|
+
food: Point;
|
|
14
|
+
cols: number;
|
|
15
|
+
rows: number;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEzD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,6EAA6E;AAC7E,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,SAAS,KAAK,EAAE,CAAC;IACxB,IAAI,EAAE,KAAK,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agencecinq/snake",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Canvas Snake as a lightweight Web Component.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jérémy Levron <jeremy@agencecinq.com> (https://agencecinq.com)",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/agencecinq/ui.git",
|
|
11
|
+
"directory": "packages/snake"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@agencecinq/utils": ">=7.3.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"vite": "^8.2.0",
|
|
30
|
+
"vite-plugin-dts": "^5.0.3",
|
|
31
|
+
"@agencecinq/utils": "7.3.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "vite build",
|
|
38
|
+
"dev": "vite build --watch"
|
|
39
|
+
}
|
|
40
|
+
}
|