@norarcasey/arkanora 0.1.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 ADDED
@@ -0,0 +1,70 @@
1
+ # Arkanora 🧱
2
+
3
+ An embeddable React + TypeScript breakout game. Slide the paddle with your
4
+ **mouse** (or the **arrow keys** / **A,D**), keep the ball alive, and smash
5
+ every brick. Miss the ball and you lose a life.
6
+
7
+ Built with **Vite** and tested with **Vitest** + **React Testing Library**.
8
+
9
+ ## Quick start
10
+
11
+ ```bash
12
+ npm install
13
+ npm run dev # demo site at http://localhost:5173
14
+ npm test # run the test suite
15
+ npm run build # build the demo site for deployment
16
+ npm run build:lib # build the embeddable component library
17
+ ```
18
+
19
+ ## Embedding the component
20
+
21
+ ```tsx
22
+ import { Arkanora } from '@norarcasey/arkanora'
23
+ import '@norarcasey/arkanora/style.css'
24
+
25
+ export function App() {
26
+ return <Arkanora />
27
+ }
28
+ ```
29
+
30
+ `react` / `react-dom` are peer dependencies you already have.
31
+
32
+ ### Props
33
+
34
+ | Prop | Type | Default | Description |
35
+ | ---------------- | ---------------- | ------------ | ---------------------------------------------------- |
36
+ | `rows` | `number` | `5` | Number of brick rows. |
37
+ | `cols` | `number` | `9` | Number of brick columns. |
38
+ | `lives` | `number` | `3` | Lives before the game is over. |
39
+ | `speed` | `number` | `16` | Milliseconds between physics ticks (lower = faster). |
40
+ | `enableKeyboard` | `boolean` | `true` | Steer the paddle with the arrow keys / A,D. |
41
+ | `title` | `string \| null` | `"Arkanora"` | Heading above the board; pass `null` to hide it. |
42
+ | `className` | `string` | — | Extra class on the root element. |
43
+
44
+ ### Headless engine
45
+
46
+ The game logic lives in a framework-free hook if you want to build your own UI:
47
+
48
+ ```tsx
49
+ import { useArkanora } from '@norarcasey/arkanora'
50
+
51
+ const game = useArkanora({ rows: 6, cols: 10 })
52
+ // game.ball, game.bricks, game.paddleX, game.score, game.lives, game.status
53
+ // game.start(), game.reset(), game.movePaddle(x), game.nudgePaddle(dx)
54
+ ```
55
+
56
+ The playfield is a fixed 100×100 unit square; positions and sizes are in those
57
+ units so the board scales to any pixel size.
58
+
59
+ ## Roadmap
60
+
61
+ This is the MVP: paddle, ball, bricks, lives, win/lose. Planned flavor —
62
+ multi-hit bricks, power-ups (multiball, wide paddle), and per-row scoring.
63
+
64
+ Rendering is currently DOM-based, which is fine at this scale; a `<canvas>`
65
+ renderer is the natural next step if we add particle effects or smooth trails
66
+ (the engine is already decoupled from the view, so it'd be a Board-only swap).
67
+
68
+ ## License
69
+
70
+ MIT © Nora Casey
@@ -0,0 +1,231 @@
1
+ import { jsxs as k, jsx as y } from "react/jsx-runtime";
2
+ import { useReducer as Y, useEffect as A, useCallback as b } from "react";
3
+ const m = 100, S = 100, x = 18, R = 2.6, _ = 92, c = 1.6, P = 1.25, E = 0.35, H = 1.05, M = 6, I = 10, X = 4, $ = 1.4, D = 1.4, B = 3, g = { rows: 5, cols: 9, lives: 3, speed: 16 };
4
+ function L(a, n, e) {
5
+ return Math.min(e, Math.max(n, a));
6
+ }
7
+ function O(a, n) {
8
+ const e = (m - 2 * B - D * (n - 1)) / n, i = [];
9
+ for (let l = 0; l < a; l++)
10
+ for (let s = 0; s < n; s++)
11
+ i.push({
12
+ x: B + s * (e + D),
13
+ y: I + l * (X + $),
14
+ w: e,
15
+ h: X,
16
+ alive: !0
17
+ });
18
+ return i;
19
+ }
20
+ function C(a) {
21
+ return {
22
+ x: a,
23
+ y: _ - c - 0.2,
24
+ vx: P * Math.sin(E),
25
+ vy: -P * Math.cos(E)
26
+ };
27
+ }
28
+ function w(a, n, e) {
29
+ return {
30
+ rows: a,
31
+ cols: n,
32
+ maxLives: e,
33
+ ball: C(m / 2),
34
+ paddleX: m / 2,
35
+ bricks: O(a, n),
36
+ status: "idle",
37
+ score: 0,
38
+ lives: e
39
+ };
40
+ }
41
+ function G(a, n) {
42
+ switch (n.type) {
43
+ case "configure":
44
+ return w(n.rows, n.cols, n.lives);
45
+ case "reset":
46
+ return w(a.rows, a.cols, a.maxLives);
47
+ case "start":
48
+ return { ...w(a.rows, a.cols, a.maxLives), status: "running" };
49
+ case "movePaddle":
50
+ return { ...a, paddleX: L(n.x, x / 2, m - x / 2) };
51
+ case "nudgePaddle":
52
+ return { ...a, paddleX: L(a.paddleX + n.dx, x / 2, m - x / 2) };
53
+ case "tick": {
54
+ if (a.status !== "running") return a;
55
+ const e = {
56
+ x: a.ball.x + a.ball.vx,
57
+ y: a.ball.y + a.ball.vy,
58
+ vx: a.ball.vx,
59
+ vy: a.ball.vy
60
+ };
61
+ e.x - c < 0 ? (e.x = c, e.vx = Math.abs(e.vx)) : e.x + c > m && (e.x = m - c, e.vx = -Math.abs(e.vx)), e.y - c < 0 && (e.y = c, e.vy = Math.abs(e.vy));
62
+ const i = x / 2;
63
+ if (e.vy > 0 && e.y + c >= _ && e.y - c <= _ + R && e.x >= a.paddleX - i && e.x <= a.paddleX + i) {
64
+ const r = L((e.x - a.paddleX) / i, -1, 1) * H;
65
+ e.vx = P * Math.sin(r), e.vy = -P * Math.cos(r), e.y = _ - c;
66
+ }
67
+ let l = a.bricks, s = a.score;
68
+ for (let t = 0; t < l.length; t++) {
69
+ const r = l[t];
70
+ if (!r.alive) continue;
71
+ const u = e.x + c > r.x && e.x - c < r.x + r.w, o = e.y + c > r.y && e.y - c < r.y + r.h;
72
+ if (!u || !o) continue;
73
+ const p = Math.min(e.x + c, r.x + r.w) - Math.max(e.x - c, r.x), h = Math.min(e.y + c, r.y + r.h) - Math.max(e.y - c, r.y);
74
+ p < h ? e.vx = -e.vx : e.vy = -e.vy, l = l.map((f, d) => d === t ? { ...f, alive: !1 } : f), s += 1;
75
+ break;
76
+ }
77
+ if (e.y - c > S) {
78
+ const t = a.lives - 1;
79
+ return t <= 0 ? { ...a, ball: e, bricks: l, score: s, lives: 0, status: "over" } : { ...a, bricks: l, score: s, lives: t, ball: C(a.paddleX) };
80
+ }
81
+ return l.every((t) => !t.alive) ? { ...a, ball: e, bricks: l, score: s, status: "won" } : { ...a, ball: e, bricks: l, score: s };
82
+ }
83
+ }
84
+ }
85
+ function T(a = {}) {
86
+ const n = a.rows ?? g.rows, e = a.cols ?? g.cols, i = a.lives ?? g.lives, l = a.speed ?? g.speed, [s, t] = Y(G, void 0, () => w(n, e, i));
87
+ A(() => {
88
+ t({ type: "configure", rows: n, cols: e, lives: i });
89
+ }, [n, e, i]);
90
+ const r = b(() => t({ type: "start" }), []), u = b(() => t({ type: "reset" }), []), o = b((h) => t({ type: "movePaddle", x: h }), []), p = b((h) => t({ type: "nudgePaddle", dx: h }), []);
91
+ return A(() => {
92
+ if (s.status !== "running") return;
93
+ const h = setInterval(() => t({ type: "tick" }), l);
94
+ return () => clearInterval(h);
95
+ }, [s.status, l]), {
96
+ width: m,
97
+ height: S,
98
+ ball: s.ball,
99
+ ballRadius: c,
100
+ paddleX: s.paddleX,
101
+ paddleWidth: x,
102
+ paddleHeight: R,
103
+ paddleY: _,
104
+ bricks: s.bricks,
105
+ status: s.status,
106
+ score: s.score,
107
+ lives: s.lives,
108
+ start: r,
109
+ reset: u,
110
+ movePaddle: o,
111
+ nudgePaddle: p
112
+ };
113
+ }
114
+ function j({
115
+ width: a,
116
+ height: n,
117
+ ball: e,
118
+ ballRadius: i,
119
+ paddleX: l,
120
+ paddleWidth: s,
121
+ paddleHeight: t,
122
+ paddleY: r,
123
+ bricks: u
124
+ }) {
125
+ const o = (d, v) => `${d / v * 100}%`, p = { aspectRatio: `${a} / ${n}` }, h = {
126
+ left: o(e.x - i, a),
127
+ top: o(e.y - i, n),
128
+ width: o(i * 2, a),
129
+ height: o(i * 2, n)
130
+ }, f = {
131
+ left: o(l - s / 2, a),
132
+ top: o(r, n),
133
+ width: o(s, a),
134
+ height: o(t, n)
135
+ };
136
+ return /* @__PURE__ */ k("div", { className: "arkanora__board", style: p, "data-testid": "board", children: [
137
+ u.map(
138
+ (d, v) => d.alive ? /* @__PURE__ */ y(
139
+ "div",
140
+ {
141
+ className: "arkanora__brick",
142
+ style: {
143
+ left: o(d.x, a),
144
+ top: o(d.y, n),
145
+ width: o(d.w, a),
146
+ height: o(d.h, n),
147
+ // Color rows along a hue ramp for a retro rainbow wall.
148
+ backgroundColor: `hsl(${d.y * 7 % 360} 80% 58%)`
149
+ },
150
+ "aria-hidden": !0
151
+ },
152
+ v
153
+ ) : null
154
+ ),
155
+ /* @__PURE__ */ y("div", { className: "arkanora__paddle", style: f, "aria-hidden": !0 }),
156
+ /* @__PURE__ */ y("div", { className: "arkanora__ball", style: h, "aria-hidden": !0 })
157
+ ] });
158
+ }
159
+ function F({
160
+ rows: a,
161
+ cols: n,
162
+ lives: e,
163
+ speed: i,
164
+ enableKeyboard: l = !0,
165
+ title: s = "Arkanora",
166
+ className: t
167
+ }) {
168
+ const r = T({ rows: a, cols: n, lives: e, speed: i }), { status: u, start: o, nudgePaddle: p } = r;
169
+ A(() => {
170
+ if (!l) return;
171
+ const d = (v) => {
172
+ const N = v.key === "ArrowLeft" || v.key === "a", W = v.key === "ArrowRight" || v.key === "d";
173
+ !N && !W || (v.preventDefault(), u !== "running" && o(), p(N ? -M : M));
174
+ };
175
+ return window.addEventListener("keydown", d), () => window.removeEventListener("keydown", d);
176
+ }, [l, u, o, p]);
177
+ const h = (d) => {
178
+ const v = d.currentTarget.getBoundingClientRect();
179
+ v.width !== 0 && r.movePaddle((d.clientX - v.left) / v.width * r.width);
180
+ }, f = u === "over" || u === "won";
181
+ return /* @__PURE__ */ k(
182
+ "section",
183
+ {
184
+ className: `arkanora${t ? ` ${t}` : ""}`,
185
+ "aria-label": s ?? "Breakout game",
186
+ children: [
187
+ /* @__PURE__ */ k("header", { className: "arkanora__header", children: [
188
+ s !== null && /* @__PURE__ */ y("h2", { className: "arkanora__title", children: s }),
189
+ /* @__PURE__ */ k("span", { className: "arkanora__stat", "aria-live": "polite", children: [
190
+ "Score: ",
191
+ r.score
192
+ ] }),
193
+ /* @__PURE__ */ k("span", { className: "arkanora__stat", "aria-live": "polite", children: [
194
+ "Lives: ",
195
+ r.lives
196
+ ] })
197
+ ] }),
198
+ /* @__PURE__ */ k("div", { className: "arkanora__stage", onPointerMove: h, children: [
199
+ /* @__PURE__ */ y(
200
+ j,
201
+ {
202
+ width: r.width,
203
+ height: r.height,
204
+ ball: r.ball,
205
+ ballRadius: r.ballRadius,
206
+ paddleX: r.paddleX,
207
+ paddleWidth: r.paddleWidth,
208
+ paddleHeight: r.paddleHeight,
209
+ paddleY: r.paddleY,
210
+ bricks: r.bricks
211
+ }
212
+ ),
213
+ u !== "running" && /* @__PURE__ */ k("div", { className: "arkanora__overlay", role: "status", children: [
214
+ u === "idle" && /* @__PURE__ */ y("p", { className: "arkanora__message", children: "Break some bricks!" }),
215
+ u === "over" && /* @__PURE__ */ k("p", { className: "arkanora__message", children: [
216
+ "Game over — score ",
217
+ r.score
218
+ ] }),
219
+ u === "won" && /* @__PURE__ */ y("p", { className: "arkanora__message", children: "You cleared the board! 🏆" }),
220
+ /* @__PURE__ */ y("button", { type: "button", className: "arkanora__button", onClick: o, children: f ? "Play again" : "Start" }),
221
+ /* @__PURE__ */ y("p", { className: "arkanora__hint", children: "Move with the mouse, or arrow keys / A,D" })
222
+ ] })
223
+ ] })
224
+ ]
225
+ }
226
+ );
227
+ }
228
+ export {
229
+ F as Arkanora,
230
+ T as useArkanora
231
+ };
@@ -0,0 +1,88 @@
1
+ import { JSX as JSX_2 } from 'react';
2
+
3
+ export declare function Arkanora({ rows, cols, lives, speed, enableKeyboard, title, className, }: ArkanoraProps): JSX_2.Element;
4
+
5
+ export declare interface ArkanoraApi {
6
+ /** Playfield width in units (always 100). */
7
+ width: number;
8
+ /** Playfield height in units (always 100). */
9
+ height: number;
10
+ ball: Ball;
11
+ ballRadius: number;
12
+ /** Center x of the paddle, in units. */
13
+ paddleX: number;
14
+ paddleWidth: number;
15
+ paddleHeight: number;
16
+ /** The paddle's top edge, in units. */
17
+ paddleY: number;
18
+ bricks: Brick[];
19
+ status: GameStatus;
20
+ score: number;
21
+ lives: number;
22
+ /** Reset the board and serve the ball. */
23
+ start: () => void;
24
+ /** Reset the board to `idle` without serving. */
25
+ reset: () => void;
26
+ /** Move the paddle so its center sits at unit-x `x` (clamped to the walls). */
27
+ movePaddle: (x: number) => void;
28
+ /** Nudge the paddle by `dx` units (clamped to the walls). */
29
+ nudgePaddle: (dx: number) => void;
30
+ }
31
+
32
+ export declare interface ArkanoraProps {
33
+ /** Number of brick rows. Default `5`. */
34
+ rows?: number;
35
+ /** Number of brick columns. Default `9`. */
36
+ cols?: number;
37
+ /** Lives before the game is over. Default `3`. */
38
+ lives?: number;
39
+ /** Milliseconds between physics ticks; lower is smoother/faster. Default `16`. */
40
+ speed?: number;
41
+ /** Steer the paddle with the arrow keys / A,D. Default `true`. */
42
+ enableKeyboard?: boolean;
43
+ /** Heading shown above the board. Pass `null` to hide it. */
44
+ title?: string | null;
45
+ /** Extra class on the root element. */
46
+ className?: string;
47
+ }
48
+
49
+ /** The ball: a position and a velocity (units per tick). */
50
+ export declare interface Ball {
51
+ x: number;
52
+ y: number;
53
+ vx: number;
54
+ vy: number;
55
+ }
56
+
57
+ /** A single brick. Axis-aligned, top-left origin. */
58
+ export declare interface Brick {
59
+ x: number;
60
+ y: number;
61
+ w: number;
62
+ h: number;
63
+ alive: boolean;
64
+ }
65
+
66
+ /**
67
+ * Lifecycle of a single game:
68
+ * - `idle` — board is set up, ball resting on the paddle, waiting to start.
69
+ * - `running` — the ball is in play.
70
+ * - `over` — the ball was missed with no lives left.
71
+ * - `won` — every brick has been cleared.
72
+ */
73
+ export declare type GameStatus = 'idle' | 'running' | 'over' | 'won';
74
+
75
+ export declare function useArkanora(options?: UseArkanoraOptions): ArkanoraApi;
76
+
77
+ export declare interface UseArkanoraOptions {
78
+ /** Number of brick rows. Default `5`. */
79
+ rows?: number;
80
+ /** Number of brick columns. Default `9`. */
81
+ cols?: number;
82
+ /** Lives before the game is over. Default `3`. */
83
+ lives?: number;
84
+ /** Milliseconds between physics ticks; lower is smoother/faster. Default `16`. */
85
+ speed?: number;
86
+ }
87
+
88
+ export { }
package/dist/style.css ADDED
@@ -0,0 +1 @@
1
+ .arkanora{--arkanora-bg: #0b1020;--arkanora-board: #0e1530;--arkanora-wall: #1b2550;--arkanora-ball: #ffffff;--arkanora-paddle: #38e1ff;--arkanora-accent: #38e1ff;--arkanora-text: #e7ecff;box-sizing:border-box;width:100%;max-width:560px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Helvetica Neue,sans-serif;color:var(--arkanora-text)}.arkanora *,.arkanora *:before,.arkanora *:after{box-sizing:border-box}.arkanora__header{display:flex;align-items:baseline;gap:1rem;margin-bottom:.75rem}.arkanora__title{margin:0 auto 0 0;font-size:1.4rem;font-weight:700}.arkanora__stat{font-variant-numeric:tabular-nums;font-weight:600;color:var(--arkanora-accent)}.arkanora__stage{position:relative;touch-action:none}.arkanora__board{position:relative;width:100%;background:radial-gradient(120% 120% at 50% 0%,var(--arkanora-board),var(--arkanora-bg));border:2px solid var(--arkanora-wall);border-radius:8px;overflow:hidden;cursor:none}.arkanora__brick{position:absolute;border-radius:2px;box-shadow:inset 0 -2px #00000040}.arkanora__paddle{position:absolute;background:var(--arkanora-paddle);border-radius:3px;box-shadow:0 0 12px var(--arkanora-paddle)}.arkanora__ball{position:absolute;background:var(--arkanora-ball);border-radius:50%;box-shadow:0 0 10px var(--arkanora-ball)}.arkanora__overlay{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;background:#060a18cc;border-radius:8px;text-align:center}.arkanora__message{margin:0;font-size:1.25rem;font-weight:700}.arkanora__hint{margin:0;font-size:.85rem;color:var(--arkanora-accent)}.arkanora__button{-webkit-appearance:none;-moz-appearance:none;appearance:none;font:inherit;font-weight:600;cursor:pointer;padding:.55rem 1.4rem;color:#06122a;background:var(--arkanora-accent);border:none;border-radius:999px;transition:filter .12s ease}.arkanora__button:hover{filter:brightness(1.1)}.arkanora__button:active{filter:brightness(.95)}
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@norarcasey/arkanora",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "An embeddable React breakout game — bounce the ball with your paddle and smash every brick.",
6
+ "license": "MIT",
7
+ "author": "Nora Casey",
8
+ "homepage": "https://github.com/norarcasey/arkanora#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/norarcasey/arkanora.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/norarcasey/arkanora/issues"
15
+ },
16
+ "keywords": [
17
+ "breakout",
18
+ "arkanoid",
19
+ "game",
20
+ "retro",
21
+ "arcade",
22
+ "react",
23
+ "component",
24
+ "typescript"
25
+ ],
26
+ "main": "./dist/arkanora.js",
27
+ "module": "./dist/arkanora.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/arkanora.js"
33
+ },
34
+ "./style.css": "./dist/style.css"
35
+ },
36
+ "sideEffects": [
37
+ "**/*.css"
38
+ ],
39
+ "files": [
40
+ "dist"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "dev": "vite",
47
+ "build": "tsc && vite build",
48
+ "build:lib": "tsc -p tsconfig.lib.json && vite build --mode lib",
49
+ "preview": "vite preview",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "typecheck": "tsc --noEmit",
53
+ "lint": "eslint .",
54
+ "format": "prettier --write .",
55
+ "format:check": "prettier --check .",
56
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build:lib"
57
+ },
58
+ "peerDependencies": {
59
+ "react": "^18.0.0 || ^19.0.0",
60
+ "react-dom": "^18.0.0 || ^19.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@eslint/js": "^9.39.4",
64
+ "@testing-library/jest-dom": "^6.4.8",
65
+ "@testing-library/react": "^16.0.1",
66
+ "@testing-library/user-event": "^14.5.2",
67
+ "@types/node": "^20.19.41",
68
+ "@types/react": "^18.3.5",
69
+ "@types/react-dom": "^18.3.0",
70
+ "@vitejs/plugin-react": "^4.3.1",
71
+ "eslint": "^9.39.4",
72
+ "eslint-config-prettier": "^9.1.2",
73
+ "eslint-plugin-react-hooks": "^5.2.0",
74
+ "eslint-plugin-react-refresh": "^0.4.26",
75
+ "globals": "^15.15.0",
76
+ "jsdom": "^25.0.0",
77
+ "prettier": "^3.8.3",
78
+ "react": "^18.3.1",
79
+ "react-dom": "^18.3.1",
80
+ "typescript": "^5.5.4",
81
+ "typescript-eslint": "^8.60.1",
82
+ "vite": "^5.4.2",
83
+ "vite-plugin-dts": "^4.0.3",
84
+ "vitest": "^2.0.5"
85
+ }
86
+ }