star-canvas 0.1.1 → 0.1.3
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/PROMPT.md +690 -0
- package/package.json +3 -2
package/PROMPT.md
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
**Installation**
|
|
2
|
+
|
|
3
|
+
First, add the package to your project:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
yarn add star-canvas
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
### Star DOM SDK
|
|
10
|
+
|
|
11
|
+
Use the **Star DOM SDK** to initialize games reliably.
|
|
12
|
+
It prevents the most common bugs:
|
|
13
|
+
|
|
14
|
+
- ✅ No "cannot read addEventListener of null"
|
|
15
|
+
- ✅ No canvas sizing/DPR/blur issues
|
|
16
|
+
- ✅ No accidentally wiping the canvas with `innerHTML`
|
|
17
|
+
- ✅ Games work identically on ALL devices (fixed 16:9 with letterboxing)
|
|
18
|
+
|
|
19
|
+
-----
|
|
20
|
+
|
|
21
|
+
### Fixed 16:9 Resolution
|
|
22
|
+
|
|
23
|
+
**Default: 640×360 (landscape) or 360×640 (portrait).** Games work identically on every device.
|
|
24
|
+
|
|
25
|
+
The SDK uses letterboxing to maintain the exact game area. This means:
|
|
26
|
+
- Positions like `x: 320, y: 180` always mean the exact center
|
|
27
|
+
- Two objects at `x: 100` and `x: 540` are always the same distance apart
|
|
28
|
+
- No "works on my screen, breaks on mobile" bugs
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// These values work identically on ALL devices:
|
|
32
|
+
const player = { x: 320, y: 300 }; // Center-bottom area
|
|
33
|
+
const enemy = { x: 600, y: 50 }; // Top-right area
|
|
34
|
+
const playerSize = 32; // Always 32px
|
|
35
|
+
const speed = 200; // Always 200px/sec
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
-----
|
|
39
|
+
|
|
40
|
+
### Golden Path (How to Use)
|
|
41
|
+
|
|
42
|
+
Import `game` and wrap your code in it. The `game` function handles DOM readiness, creates a canvas and a UI overlay, and gives you a safe context to build.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { game } from 'star-canvas';
|
|
46
|
+
|
|
47
|
+
game(({ ctx, width, height, on, loop, ui, canvas }) => {
|
|
48
|
+
// ctx: The 2D canvas context
|
|
49
|
+
// width, height: The logical size (CSS pixels) - READ-ONLY
|
|
50
|
+
// on: Safe, delegated event listener
|
|
51
|
+
// loop: Stable game loop (with dt)
|
|
52
|
+
// ui: Safe overlay for HTML
|
|
53
|
+
// canvas: The <canvas> element
|
|
54
|
+
|
|
55
|
+
// 1. Draw on the canvas
|
|
56
|
+
loop((dt) => {
|
|
57
|
+
ctx.clearRect(0, 0, width, height);
|
|
58
|
+
ctx.fillStyle = '#3b82f6'; // blue-500
|
|
59
|
+
ctx.fillRect(width / 2 - 25, height / 2 - 25, 50, 50);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 2. Render HTML to the safe UI overlay
|
|
63
|
+
// UI is interactive by default (scroll, buttons work)
|
|
64
|
+
// Adding canvas.addEventListener makes UI click-through automatically
|
|
65
|
+
ui.render(`
|
|
66
|
+
<div class="absolute top-4 left-4 text-white">
|
|
67
|
+
<button id="start-btn" class="px-4 py-2 bg-blue-500 rounded pointer-events-auto">
|
|
68
|
+
Click Me
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
`);
|
|
72
|
+
|
|
73
|
+
// 3. Listen for button clicks
|
|
74
|
+
on('click', '#start-btn', () => {
|
|
75
|
+
console.log('Button clicked!');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 4. For canvas games: listen for taps on canvas
|
|
79
|
+
// This automatically makes UI click-through (taps pass through to canvas)
|
|
80
|
+
// Buttons with pointer-events-auto still work
|
|
81
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
82
|
+
console.log('Canvas/screen tapped!', e);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
> **CRITICAL:** Always import the SDK in your JavaScript/TypeScript.
|
|
88
|
+
> **Do not** add a `<script src="/star-sdk/dom.js">` tag in HTML.
|
|
89
|
+
>
|
|
90
|
+
> **Recommended Import:**
|
|
91
|
+
>
|
|
92
|
+
> ```ts
|
|
93
|
+
> import { game } from 'star-canvas';
|
|
94
|
+
> ```
|
|
95
|
+
|
|
96
|
+
-----
|
|
97
|
+
|
|
98
|
+
## Core API: `game(setup, options?)`
|
|
99
|
+
|
|
100
|
+
The `setup` function receives one argument: a `GameContext` object with the following properties:
|
|
101
|
+
|
|
102
|
+
### `ctx: CanvasRenderingContext2D`
|
|
103
|
+
|
|
104
|
+
The 2D drawing context. Its transform is already scaled for DPR. You **always draw in logical CSS pixels**.
|
|
105
|
+
|
|
106
|
+
### `canvas: HTMLCanvasElement`
|
|
107
|
+
|
|
108
|
+
The `<canvas>` element itself.
|
|
109
|
+
|
|
110
|
+
- **Use this for gameplay input listeners** (e.g., `pointerdown`, `pointermove`).
|
|
111
|
+
|
|
112
|
+
### `width: number` (getter)
|
|
113
|
+
|
|
114
|
+
### `height: number` (getter)
|
|
115
|
+
|
|
116
|
+
The logical CSS pixel width and height of the stage. **Use these for all game logic and drawing.** They are getters, so they are always up-to-date.
|
|
117
|
+
|
|
118
|
+
### `on(type, selector, handler, options?)`
|
|
119
|
+
|
|
120
|
+
Attaches a **delegated event listener** to the document.
|
|
121
|
+
|
|
122
|
+
- ✅ **Use this for UI elements** (buttons, menus) inside your `ui.render()` HTML.
|
|
123
|
+
- ✅ Survives `ui.render()` calls.
|
|
124
|
+
- Returns an `off()` function to unsubscribe.
|
|
125
|
+
|
|
126
|
+
### `loop(tick)`
|
|
127
|
+
|
|
128
|
+
Starts a `requestAnimationFrame` loop.
|
|
129
|
+
|
|
130
|
+
- `tick` function receives `(dt, now)`, where `dt` is **delta time in seconds**.
|
|
131
|
+
- **ALWAYS** multiply movement by `dt` (e.g., `player.x += speed * dt`).
|
|
132
|
+
- Returns `{ start(), stop(), running }`. The loop starts automatically.
|
|
133
|
+
|
|
134
|
+
### `ui: GameUI`
|
|
135
|
+
|
|
136
|
+
A safe manager for your HTML overlay, stacked on top of the canvas.
|
|
137
|
+
|
|
138
|
+
- `ui.root`: The `<div>` element for your UI. It is **interactive by default** (standard HTML behavior - scroll, buttons work).
|
|
139
|
+
- `ui.render(html: string)`: **Use this** to set your UI. It's safe and won't destroy the canvas.
|
|
140
|
+
- Automatically skips updates if HTML is unchanged (safe to call in loop for static content)
|
|
141
|
+
- For best performance with dynamic content (score), only call when values actually change
|
|
142
|
+
- `ui.el(selector)`: Scoped `querySelector` for the UI root.
|
|
143
|
+
- `ui.all(selector)`: Scoped `querySelectorAll` for the UI root.
|
|
144
|
+
|
|
145
|
+
**Auto-detection:** When you add `canvas.addEventListener('pointerdown', ...)`, the SDK automatically makes UI click-through so taps reach the canvas. Buttons with `pointer-events-auto` still work.
|
|
146
|
+
|
|
147
|
+
### Cursor Management
|
|
148
|
+
|
|
149
|
+
**CRITICAL:** Choose cursor based on how players interact. Update cursor when state changes (e.g., menu → playing → gameover).
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// MOUSE-BASED GAMES (click/point-and-click/puzzle/clicker/strategy/constellation)
|
|
153
|
+
if (state === 'playing') canvas.style.cursor = 'pointer'; // Show where to click
|
|
154
|
+
if (state === 'menu' || state === 'gameover') canvas.style.cursor = 'pointer'; // Keep visible
|
|
155
|
+
|
|
156
|
+
// PRECISION AIMING (shooter/drawing/building)
|
|
157
|
+
if (state === 'playing') canvas.style.cursor = 'crosshair';
|
|
158
|
+
if (state === 'menu' || state === 'gameover') canvas.style.cursor = 'auto';
|
|
159
|
+
|
|
160
|
+
// KEYBOARD/TOUCH ONLY (platformer/WASD/rhythm/endless runner)
|
|
161
|
+
if (state === 'playing') canvas.style.cursor = 'none'; // Hide (doesn't matter)
|
|
162
|
+
if (state === 'menu' || state === 'gameover') canvas.style.cursor = 'auto'; // Show for menus!
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Decision:** Does player click on game objects? → `'pointer'` | Aim precisely? → `'crosshair'` | WASD/touch only? → `'none'` during play, `'auto'` for menus
|
|
166
|
+
|
|
167
|
+
### `toStagePoint(event)`
|
|
168
|
+
|
|
169
|
+
Converts `MouseEvent` or `PointerEvent` client coordinates to the stage's logical coordinates.
|
|
170
|
+
|
|
171
|
+
- **USE THIS** for all canvas pointer input.
|
|
172
|
+
|
|
173
|
+
### `createDrag()`
|
|
174
|
+
|
|
175
|
+
Creates a drag state helper that handles coordinate conversion and offset tracking automatically.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const drag = createDrag();
|
|
179
|
+
|
|
180
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
181
|
+
canvas.setPointerCapture(e.pointerId); // IMPORTANT: Capture for reliable drags
|
|
182
|
+
const { x, y } = drag.point(e); // Convert coordinates
|
|
183
|
+
const hit = pieces.find(p => /* hit test */);
|
|
184
|
+
if (hit) drag.grab(e, hit); // Start drag with offset
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
canvas.addEventListener('pointermove', (e) => drag.move(e)); // Updates position
|
|
188
|
+
canvas.addEventListener('pointerup', () => {
|
|
189
|
+
const dropped = drag.release(); // Returns dropped object (or null)
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**API:**
|
|
194
|
+
- `point(e)` - Pure coordinate conversion, no side effects
|
|
195
|
+
- `grab(e, obj)` - Start dragging an object, computing offset from cursor
|
|
196
|
+
- `move(e)` - Update dragged object's position
|
|
197
|
+
- `release()` - End drag, returns dropped object or null
|
|
198
|
+
- `dragging` - The currently dragged object (or null)
|
|
199
|
+
|
|
200
|
+
### `GameOptions` (optional)
|
|
201
|
+
|
|
202
|
+
Pass an options object as the second argument to `game()`:
|
|
203
|
+
|
|
204
|
+
- `preset?: 'landscape' | 'portrait' | 'responsive'`: Game orientation preset.
|
|
205
|
+
- `'landscape'` (default): 640×360 - for platformers, shooters, racing
|
|
206
|
+
- `'portrait'`: 360×640 - for puzzle, cards, match-3, mobile-style
|
|
207
|
+
- `'responsive'`: Fills container, no fixed dimensions (legacy - gameplay varies by device)
|
|
208
|
+
- `width?: number`: Override width (default: 640 for landscape, 360 for portrait)
|
|
209
|
+
- `height?: number`: Override height (default: 360 for landscape, 640 for portrait)
|
|
210
|
+
- `fit?: 'contain' | 'cover' | 'stretch'`: How game fits container (default: `'contain'` with letterboxing)
|
|
211
|
+
- `pixelRatio?: 'device' | number`: (default: `'device'`)
|
|
212
|
+
- `maxPixelRatio?: number`: (default: `2`)
|
|
213
|
+
- `preventContextMenu?: boolean`: Prevent right-click context menu on canvas (default: `true`)
|
|
214
|
+
|
|
215
|
+
**Default behavior:** Fixed 640×360 (16:9) with letterboxing. Games work identically on all devices.
|
|
216
|
+
|
|
217
|
+
-----
|
|
218
|
+
|
|
219
|
+
## Recipes
|
|
220
|
+
|
|
221
|
+
### Recipe 1: UI-Only Game (e.g., Clicker)
|
|
222
|
+
|
|
223
|
+
Use `game`, `on`, and `ui`.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { game } from 'star-canvas';
|
|
227
|
+
|
|
228
|
+
game(({ on, ui }) => {
|
|
229
|
+
let score = 0;
|
|
230
|
+
|
|
231
|
+
function render() {
|
|
232
|
+
// UI is interactive by default - buttons, scroll, forms all work
|
|
233
|
+
ui.render(`
|
|
234
|
+
<div class="min-h-[100dvh] grid place-items-center bg-gray-900 text-white">
|
|
235
|
+
<div class="text-center space-y-4">
|
|
236
|
+
<h1 class="text-4xl font-bold">Score: \${score}</h1>
|
|
237
|
+
<button id="clickBtn" class="px-8 py-4 rounded-xl bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg shadow-blue-500/20 font-bold">
|
|
238
|
+
Click Me!
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Button clicks work by default
|
|
246
|
+
on('click', '#clickBtn', () => {
|
|
247
|
+
score++;
|
|
248
|
+
render();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
render();
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Recipe 2: Canvas Game (Landscape)
|
|
256
|
+
|
|
257
|
+
Default pattern - fixed 640×360 resolution. Games work identically on all devices.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { game } from 'star-canvas';
|
|
261
|
+
|
|
262
|
+
game(({ ctx, width, height, loop }) => {
|
|
263
|
+
// width = 640, height = 360 (always, with letterboxing)
|
|
264
|
+
const playerSize = 32;
|
|
265
|
+
const speed = 200; // 200px per second
|
|
266
|
+
|
|
267
|
+
const player = { x: 64, y: 180 }; // Fixed positions work everywhere
|
|
268
|
+
|
|
269
|
+
loop((dt) => {
|
|
270
|
+
player.x += speed * dt;
|
|
271
|
+
if (player.x > width) player.x = -playerSize;
|
|
272
|
+
|
|
273
|
+
ctx.fillStyle = '#111827';
|
|
274
|
+
ctx.fillRect(0, 0, width, height);
|
|
275
|
+
|
|
276
|
+
ctx.fillStyle = '#3b82f6';
|
|
277
|
+
ctx.fillRect(player.x, player.y - playerSize/2, playerSize, playerSize);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
// Default: 640×360 landscape with letterboxing
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Recipe 3: Canvas Game (Portrait)
|
|
284
|
+
|
|
285
|
+
For puzzle games, card games, match-3, mobile-style games - use portrait preset.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { game } from 'star-canvas';
|
|
289
|
+
|
|
290
|
+
game(({ ctx, width, height, loop, canvas, toStagePoint }) => {
|
|
291
|
+
// width = 360, height = 640 (always, with letterboxing)
|
|
292
|
+
const cellSize = 40;
|
|
293
|
+
const gridCols = 8;
|
|
294
|
+
const gridRows = 12;
|
|
295
|
+
|
|
296
|
+
// Center the grid
|
|
297
|
+
const gridWidth = gridCols * cellSize;
|
|
298
|
+
const gridX = (width - gridWidth) / 2;
|
|
299
|
+
const gridY = 80;
|
|
300
|
+
|
|
301
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
302
|
+
const { x, y } = toStagePoint(e);
|
|
303
|
+
// Handle tap on grid...
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
loop((dt) => {
|
|
307
|
+
ctx.fillStyle = '#111827';
|
|
308
|
+
ctx.fillRect(0, 0, width, height);
|
|
309
|
+
|
|
310
|
+
ctx.strokeStyle = '#8b5cf6';
|
|
311
|
+
for (let row = 0; row < gridRows; row++) {
|
|
312
|
+
for (let col = 0; col < gridCols; col++) {
|
|
313
|
+
ctx.strokeRect(
|
|
314
|
+
gridX + col * cellSize,
|
|
315
|
+
gridY + row * cellSize,
|
|
316
|
+
cellSize, cellSize
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}, { preset: 'portrait' }); // 360×640 portrait with letterboxing
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Recipe 4: Custom Resolution
|
|
325
|
+
|
|
326
|
+
For games that need different dimensions (e.g., pixel art at 320×180).
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { game } from 'star-canvas';
|
|
330
|
+
|
|
331
|
+
game(({ ctx, width, height, loop, toStagePoint, canvas }) => {
|
|
332
|
+
// Custom 320×180 resolution (retro pixel art style)
|
|
333
|
+
const player = { x: 160, y: 90 }; // Center
|
|
334
|
+
|
|
335
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
336
|
+
const { x, y } = toStagePoint(e);
|
|
337
|
+
console.log('Tapped at:', x, y); // Always 0-320, 0-180
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
loop((dt) => {
|
|
341
|
+
ctx.fillStyle = '#111827';
|
|
342
|
+
ctx.fillRect(0, 0, width, height);
|
|
343
|
+
|
|
344
|
+
ctx.fillStyle = '#3b82f6';
|
|
345
|
+
ctx.fillRect(player.x - 8, player.y - 8, 16, 16);
|
|
346
|
+
});
|
|
347
|
+
}, { width: 320, height: 180 }); // Custom resolution with letterboxing
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Recipe 5: Complex Game with Canvas + UI + Events (like FLOW)
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import { game } from 'star-canvas';
|
|
354
|
+
import { createLeaderboard } from 'star-leaderboard';
|
|
355
|
+
|
|
356
|
+
const leaderboard = createLeaderboard();
|
|
357
|
+
|
|
358
|
+
game(({ ctx, width, height, loop, ui, on, canvas, toStagePoint }) => {
|
|
359
|
+
let score = 0;
|
|
360
|
+
let state = 'menu';
|
|
361
|
+
|
|
362
|
+
function handleTap() {
|
|
363
|
+
if (state === 'menu' || state === 'gameover') {
|
|
364
|
+
startGame();
|
|
365
|
+
} else if (state === 'playing') {
|
|
366
|
+
// ... (player float logic) ...
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 1. Listen for screen taps - this makes UI click-through automatically
|
|
371
|
+
canvas.addEventListener('pointerdown', handleTap);
|
|
372
|
+
|
|
373
|
+
// 2. Listen for button clicks - buttons need pointer-events-auto
|
|
374
|
+
on('click', '#leaderboard-btn', (e) => {
|
|
375
|
+
e.stopPropagation();
|
|
376
|
+
leaderboard.show();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// 3. Render UI - buttons need pointer-events-auto to intercept clicks
|
|
380
|
+
let lastState = null;
|
|
381
|
+
let lastScore = -1;
|
|
382
|
+
|
|
383
|
+
function updateUI() {
|
|
384
|
+
// CRITICAL: Only render when state/score changes, NOT every frame
|
|
385
|
+
// Calling ui.render() in the loop breaks buttons (DOM recreation)
|
|
386
|
+
if (state === lastState && score === lastScore) return;
|
|
387
|
+
lastState = state;
|
|
388
|
+
lastScore = score;
|
|
389
|
+
|
|
390
|
+
if (state === 'menu') {
|
|
391
|
+
ui.render(`
|
|
392
|
+
<div class="h-full flex flex-col items-center justify-center text-white">
|
|
393
|
+
<h1 class="text-6xl font-bold mb-4">FLOW</h1>
|
|
394
|
+
<div class="text-2xl animate-pulse">TAP TO START</div>
|
|
395
|
+
</div>`);
|
|
396
|
+
} else if (state === 'playing') {
|
|
397
|
+
ui.render(`
|
|
398
|
+
<div class="absolute top-8 left-1/2 -translate-x-1/2 text-white">
|
|
399
|
+
<div class="text-5xl font-bold">\${score}</div>
|
|
400
|
+
</div>`);
|
|
401
|
+
} else if (state === 'gameover') {
|
|
402
|
+
ui.render(`
|
|
403
|
+
<div class="h-full flex flex-col items-center justify-center text-white">
|
|
404
|
+
<div class="text-3xl mb-4">GAME OVER</div>
|
|
405
|
+
<div class="text-6xl mb-4">\${score}</div>
|
|
406
|
+
<button id="leaderboard-btn" class="px-6 py-3 mb-4 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl font-bold shadow-lg shadow-blue-500/20 pointer-events-auto">
|
|
407
|
+
VIEW LEADERBOARD
|
|
408
|
+
</button>
|
|
409
|
+
<div class="text-xl animate-pulse">TAP TO RESTART</div>
|
|
410
|
+
</div>`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 4. Call updateUI when state changes (NOT every frame)
|
|
415
|
+
updateUI();
|
|
416
|
+
|
|
417
|
+
// Update when state transitions happen
|
|
418
|
+
function startGame() {
|
|
419
|
+
state = 'playing';
|
|
420
|
+
score = 0;
|
|
421
|
+
updateUI();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function endGame() {
|
|
425
|
+
state = 'gameover';
|
|
426
|
+
// Submit score to leaderboard
|
|
427
|
+
leaderboard.submit(score);
|
|
428
|
+
updateUI();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Recipe 6: Safe Canvas Transforms (scoped)
|
|
434
|
+
|
|
435
|
+
When applying temporary transforms (translate, rotate, scale), use `scoped()` to automatically restore the context state:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { game } from 'star-canvas';
|
|
439
|
+
|
|
440
|
+
game(({ ctx, scoped, loop }) => {
|
|
441
|
+
const cards = [
|
|
442
|
+
{ x: 100, y: 100, angle: 0.1, visible: true },
|
|
443
|
+
{ x: 200, y: 150, angle: -0.2, visible: true },
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
function drawCard(card) {
|
|
447
|
+
scoped(() => {
|
|
448
|
+
ctx.translate(card.x, card.y);
|
|
449
|
+
ctx.rotate(card.angle);
|
|
450
|
+
if (!card.visible) return; // Safe! restore() still happens
|
|
451
|
+
ctx.fillStyle = '#3b82f6';
|
|
452
|
+
ctx.fillRect(-40, -60, 80, 120);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
loop(() => {
|
|
457
|
+
ctx.clearRect(0, 0, 800, 600);
|
|
458
|
+
cards.forEach(drawCard);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Why use `scoped()`:** Prevents transform stack corruption from early returns, exceptions, or forgetting `ctx.restore()`. The context is always restored, even if the function exits early.
|
|
464
|
+
|
|
465
|
+
### Recipe 7: Drag and Drop with createDrag() (RECOMMENDED)
|
|
466
|
+
|
|
467
|
+
Use the `createDrag()` helper - it handles coordinate conversion and offset tracking automatically.
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import { game } from 'star-canvas';
|
|
471
|
+
|
|
472
|
+
game(({ ctx, width, height, loop, canvas, createDrag }) => {
|
|
473
|
+
// Size relative to height for consistency
|
|
474
|
+
const pieceSize = height * 0.15;
|
|
475
|
+
|
|
476
|
+
const pieces = [
|
|
477
|
+
{ x: width * 0.2, y: height * 0.3, color: '#ef4444' },
|
|
478
|
+
{ x: width * 0.4, y: height * 0.4, color: '#10b981' },
|
|
479
|
+
{ x: width * 0.6, y: height * 0.3, color: '#3b82f6' },
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
// Create drag helper - handles coordinate conversion automatically
|
|
483
|
+
const drag = createDrag();
|
|
484
|
+
|
|
485
|
+
function hitTest(x, y) {
|
|
486
|
+
for (let i = pieces.length - 1; i >= 0; i--) {
|
|
487
|
+
const p = pieces[i];
|
|
488
|
+
if (x >= p.x && x < p.x + pieceSize && y >= p.y && y < p.y + pieceSize) {
|
|
489
|
+
return p;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
496
|
+
canvas.setPointerCapture(e.pointerId); // IMPORTANT: Ensures drag works outside canvas
|
|
497
|
+
const { x, y } = drag.point(e); // Convert coordinates
|
|
498
|
+
const hit = hitTest(x, y);
|
|
499
|
+
if (hit) {
|
|
500
|
+
drag.grab(e, hit); // Start drag with offset from cursor
|
|
501
|
+
canvas.style.cursor = 'grabbing';
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
506
|
+
drag.move(e); // Updates grabbed object position
|
|
507
|
+
if (!drag.dragging) {
|
|
508
|
+
const { x, y } = drag.point(e);
|
|
509
|
+
canvas.style.cursor = hitTest(x, y) ? 'grab' : 'default';
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
canvas.addEventListener('pointerup', () => {
|
|
514
|
+
const dropped = drag.release(); // Returns dropped object (or null)
|
|
515
|
+
if (dropped) {
|
|
516
|
+
console.log('Dropped:', dropped);
|
|
517
|
+
}
|
|
518
|
+
canvas.style.cursor = 'default';
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
loop(() => {
|
|
522
|
+
ctx.fillStyle = '#1f2937';
|
|
523
|
+
ctx.fillRect(0, 0, width, height);
|
|
524
|
+
|
|
525
|
+
for (const p of pieces) {
|
|
526
|
+
ctx.fillStyle = drag.dragging === p ? '#f59e0b' : p.color;
|
|
527
|
+
ctx.fillRect(p.x, p.y, pieceSize, pieceSize);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
**CRITICAL: Always use `setPointerCapture()`** - This ensures drags work even when the pointer moves outside the canvas. Without it, fast drags can leave objects stuck mid-drag.
|
|
534
|
+
|
|
535
|
+
### Recipe 8: Drag and Drop (Manual Pattern)
|
|
536
|
+
|
|
537
|
+
If you need more control, here's the manual approach with `toStagePoint()`.
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
import { game } from 'star-canvas';
|
|
541
|
+
|
|
542
|
+
game(({ ctx, width, height, loop, canvas, toStagePoint }) => {
|
|
543
|
+
const pieceSize = height * 0.15;
|
|
544
|
+
const pieces = [
|
|
545
|
+
{ x: width * 0.2, y: height * 0.3, color: '#ef4444' },
|
|
546
|
+
{ x: width * 0.4, y: height * 0.4, color: '#10b981' },
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
// Manual drag state
|
|
550
|
+
let dragging = null;
|
|
551
|
+
let dragOffsetX = 0;
|
|
552
|
+
let dragOffsetY = 0;
|
|
553
|
+
|
|
554
|
+
function hitTest(px, py) {
|
|
555
|
+
for (let i = pieces.length - 1; i >= 0; i--) {
|
|
556
|
+
const p = pieces[i];
|
|
557
|
+
if (px >= p.x && px < p.x + pieceSize && py >= p.y && py < p.y + pieceSize) {
|
|
558
|
+
return p;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
565
|
+
canvas.setPointerCapture(e.pointerId); // Ensures drag works outside canvas
|
|
566
|
+
const { x, y } = toStagePoint(e); // CRITICAL: Convert coordinates!
|
|
567
|
+
const hit = hitTest(x, y);
|
|
568
|
+
if (hit) {
|
|
569
|
+
dragging = hit;
|
|
570
|
+
dragOffsetX = x - hit.x; // Store offset
|
|
571
|
+
dragOffsetY = y - hit.y;
|
|
572
|
+
canvas.style.cursor = 'grabbing';
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
577
|
+
const { x, y } = toStagePoint(e); // CRITICAL: Convert here too!
|
|
578
|
+
if (dragging) {
|
|
579
|
+
dragging.x = x - dragOffsetX;
|
|
580
|
+
dragging.y = y - dragOffsetY;
|
|
581
|
+
} else {
|
|
582
|
+
canvas.style.cursor = hitTest(x, y) ? 'grab' : 'default';
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
canvas.addEventListener('pointerup', () => {
|
|
587
|
+
dragging = null;
|
|
588
|
+
canvas.style.cursor = 'default';
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
loop(() => {
|
|
592
|
+
ctx.fillStyle = '#1f2937';
|
|
593
|
+
ctx.fillRect(0, 0, width, height);
|
|
594
|
+
|
|
595
|
+
for (const p of pieces) {
|
|
596
|
+
ctx.fillStyle = dragging === p ? '#f59e0b' : p.color;
|
|
597
|
+
ctx.fillRect(p.x, p.y, pieceSize, pieceSize);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
**Common Drag-Drop Mistakes:**
|
|
604
|
+
|
|
605
|
+
1. ❌ Forgetting `toStagePoint()` in pointermove → `createDrag()` fixes this
|
|
606
|
+
2. ❌ No drag offset (piece "jumps" to cursor) → `createDrag()` fixes this
|
|
607
|
+
3. ❌ Using `e.clientX/clientY` directly → `createDrag()` fixes this
|
|
608
|
+
4. ❌ Not clearing state on pointerup → `createDrag()` fixes this
|
|
609
|
+
5. ❌ Missing `setPointerCapture()` (drags break outside canvas) → **You must add this!**
|
|
610
|
+
|
|
611
|
+
**Recommendation:** Use `createDrag()` + `setPointerCapture()` for bulletproof drag-and-drop.
|
|
612
|
+
|
|
613
|
+
### Recipe 9: Image Backgrounds
|
|
614
|
+
|
|
615
|
+
Two patterns for backgrounds: **full-canvas** (unique scenes) or **tileable patterns** (repeating textures).
|
|
616
|
+
|
|
617
|
+
**Full-canvas background (scaled to fit):**
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
import { game } from 'star-canvas';
|
|
621
|
+
|
|
622
|
+
game(({ ctx, width, height, loop }) => {
|
|
623
|
+
const bg = new Image();
|
|
624
|
+
bg.src = 'https://example.com/background.png'; // Use generated asset URL
|
|
625
|
+
|
|
626
|
+
const player = { x: 320, y: 300 };
|
|
627
|
+
|
|
628
|
+
loop((dt) => {
|
|
629
|
+
// Draw background scaled to canvas (no tiling)
|
|
630
|
+
if (bg.complete) {
|
|
631
|
+
ctx.drawImage(bg, 0, 0, width, height);
|
|
632
|
+
} else {
|
|
633
|
+
ctx.fillStyle = '#1f2937'; // Fallback color while loading
|
|
634
|
+
ctx.fillRect(0, 0, width, height);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Draw game objects on top
|
|
638
|
+
ctx.fillStyle = '#3b82f6';
|
|
639
|
+
ctx.fillRect(player.x - 16, player.y - 16, 32, 32);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
**Tileable pattern background (repeating texture):**
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
import { game } from 'star-canvas';
|
|
648
|
+
|
|
649
|
+
game(({ ctx, width, height, loop }) => {
|
|
650
|
+
const tile = new Image();
|
|
651
|
+
tile.src = 'https://example.com/grass_tile.png'; // Use generated asset URL (seamlessTile: true)
|
|
652
|
+
|
|
653
|
+
let pattern = null;
|
|
654
|
+
tile.onload = () => {
|
|
655
|
+
pattern = ctx.createPattern(tile, 'repeat');
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const player = { x: 320, y: 300 };
|
|
659
|
+
|
|
660
|
+
loop((dt) => {
|
|
661
|
+
// Draw tiled background
|
|
662
|
+
if (pattern) {
|
|
663
|
+
ctx.fillStyle = pattern;
|
|
664
|
+
ctx.fillRect(0, 0, width, height);
|
|
665
|
+
} else {
|
|
666
|
+
ctx.fillStyle = '#10b981'; // Fallback color while loading
|
|
667
|
+
ctx.fillRect(0, 0, width, height);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Draw game objects on top
|
|
671
|
+
ctx.fillStyle = '#3b82f6';
|
|
672
|
+
ctx.fillRect(player.x - 16, player.y - 16, 32, 32);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**When to use which:**
|
|
678
|
+
|
|
679
|
+
| Type | Size | Use Case | Generation Settings |
|
|
680
|
+
|------|------|----------|---------------------|
|
|
681
|
+
| Full-canvas | 1024×1024 | Unique scenes, landscapes, detailed environments | `model: "gemini"`, no `seamlessTile` |
|
|
682
|
+
| Tileable (all directions) | 256×256 or 512×512 | Grass, water, brick, abstract patterns | `model: "gemini"`, `seamlessTile: "both"` |
|
|
683
|
+
| Horizontal tiling | 256×512 or similar | Side-scroller parallax layers, horizon lines | `model: "gemini"`, `seamlessTile: "horizontal"` |
|
|
684
|
+
| Vertical tiling | 512×256 or similar | Vertical scroller backgrounds | `model: "gemini"`, `seamlessTile: "vertical"` |
|
|
685
|
+
|
|
686
|
+
**Common mistakes:**
|
|
687
|
+
- ❌ Generating a detailed scene and expecting it to tile → Use `seamlessTile` only for patterns
|
|
688
|
+
- ❌ Using `drawImage()` without size params → Image won't scale to canvas
|
|
689
|
+
- ❌ Not handling image load state → Blank canvas until loaded
|
|
690
|
+
- ❌ Using `seamlessTile: "both"` when you only need one direction → AI has better success with single-axis tiling
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "star-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas game utilities for reliable game initialization - part of Star SDK.",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"main": "./dist/index.cjs",
|
|
41
41
|
"types": "./dist/index.d.ts",
|
|
42
42
|
"files": [
|
|
43
|
-
"dist"
|
|
43
|
+
"dist",
|
|
44
|
+
"PROMPT.md"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "tsup",
|