castle-web-cli 0.4.84 → 0.4.86
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/dist/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/basic-2d/CLAUDE.md +3 -3
- package/kits/basic-2d/behaviors/Collider.jsx +172 -26
- package/kits/basic-2d/behaviors/Layout.jsx +24 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
- package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
- package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
- package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
- package/kits/basic-2d/engine/collider.js +60 -6
- package/kits/basic-2d/engine/scene.js +28 -2
- package/kits/basic-2d/engine/systemRegistry.js +12 -0
- package/kits/basic-2d/engine/ui.jsx +2 -0
- package/kits/basic-2d/main.jsx +3 -2
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +213 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +200 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +685 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -1
|
@@ -113,13 +113,16 @@ function manualRect(layout, collider) {
|
|
|
113
113
|
|
|
114
114
|
const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
|
|
115
115
|
|
|
116
|
-
// The un-offset rect
|
|
117
|
-
//
|
|
118
|
-
//
|
|
116
|
+
// The un-offset rect. Colliders are sized by their explicit `width`/`height`
|
|
117
|
+
// (the "auto vs manual" mode is gone -- use the inspector's "Auto-fit to sprite"
|
|
118
|
+
// action to snap those to the sprite). Legacy decks that still carry
|
|
119
|
+
// `mode: 'auto'` keep their dynamic sprite fit for back-compat.
|
|
119
120
|
function modeRect(layout, collider, spriteProps, sprites) {
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if (collider.mode === 'auto') {
|
|
122
|
+
const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
|
|
123
|
+
return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
|
|
124
|
+
}
|
|
125
|
+
return manualRect(layout, collider);
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
// The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
|
|
@@ -141,6 +144,57 @@ export function getColliderRect(actor, sprites) {
|
|
|
141
144
|
};
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
// Full collider geometry: the AABB rect (`x/y/width/height`, offset-applied)
|
|
148
|
+
// PLUS the shape and, for circles, the center + radius. This is the SINGLE
|
|
149
|
+
// SOURCE OF TRUTH for collider shape -- the physics body (matterBridge), the
|
|
150
|
+
// play-mode debug draw (Collider.draw), and the editor selection overlay all
|
|
151
|
+
// derive their geometry from here, so the visual preview always matches the
|
|
152
|
+
// simulated collider. `radius` of 0 means "derive from the rect" (min side / 2),
|
|
153
|
+
// so a circle shows a real size even before you set an explicit radius.
|
|
154
|
+
export function getColliderShape(actor, sprites) {
|
|
155
|
+
const rect = getColliderRect(actor, sprites);
|
|
156
|
+
if (!rect) return null;
|
|
157
|
+
const collider = actor.components.Collider;
|
|
158
|
+
const shape = collider.shape === 'circle' ? 'circle' : 'box';
|
|
159
|
+
const radius = collider.radius > 0 ? collider.radius : Math.min(rect.width, rect.height) / 2;
|
|
160
|
+
return {
|
|
161
|
+
shape,
|
|
162
|
+
x: rect.x,
|
|
163
|
+
y: rect.y,
|
|
164
|
+
width: rect.width,
|
|
165
|
+
height: rect.height,
|
|
166
|
+
cx: rect.x + rect.width / 2,
|
|
167
|
+
cy: rect.y + rect.height / 2,
|
|
168
|
+
radius,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The explicit `width`/`height`/`offsetX`/`offsetY` a collider would need to
|
|
173
|
+
// exactly match its sprite's opaque-pixel fit -- what "auto" used to compute
|
|
174
|
+
// dynamically. Returns null when there's nothing to fit to (no Sprite, tile
|
|
175
|
+
// mode, or fully transparent art). Powers the inspector's "Auto-fit to sprite".
|
|
176
|
+
export function computeAutoFit(actor, sprites) {
|
|
177
|
+
const rawLayout = actor?.components?.Layout;
|
|
178
|
+
const spriteProps = actor?.components?.Sprite;
|
|
179
|
+
if (!rawLayout || !spriteProps) return null;
|
|
180
|
+
// Blueprint templates omit x/y (position is instance-local). x/y cancel out of
|
|
181
|
+
// the offset delta below, so default them to 0 -- otherwise an undefined x/y
|
|
182
|
+
// (auto-fitting a template, e.g. on add) turns the offsets into NaN.
|
|
183
|
+
const layout = { ...rawLayout, x: rawLayout.x ?? 0, y: rawLayout.y ?? 0 };
|
|
184
|
+
const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
|
|
185
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
|
|
186
|
+
// manualRect centers a width x height box in the Layout box; the offset is the
|
|
187
|
+
// delta from that centered position to the sprite-fit rect.
|
|
188
|
+
const centeredX = layout.x + (layout.width - rect.width) / 2;
|
|
189
|
+
const centeredY = layout.y + (layout.height - rect.height) / 2;
|
|
190
|
+
return {
|
|
191
|
+
width: Math.round(rect.width),
|
|
192
|
+
height: Math.round(rect.height),
|
|
193
|
+
offsetX: Math.round(rect.x - centeredX),
|
|
194
|
+
offsetY: Math.round(rect.y - centeredY),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
144
198
|
export function intersects(a, b) {
|
|
145
199
|
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
146
200
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { initialFiles, parseJsonFile } from './files';
|
|
2
2
|
import { getBlueprintTemplate, mergeComponents } from './blueprint';
|
|
3
3
|
import { getColliderRect, intersects, spriteIsEmpty } from './collider';
|
|
4
|
+
import { systemInstallers } from './systemRegistry';
|
|
4
5
|
|
|
5
6
|
const CARD_WIDTH = 500;
|
|
6
7
|
const CARD_HEIGHT = 700;
|
|
@@ -42,9 +43,21 @@ export class SceneRuntime {
|
|
|
42
43
|
this.actors = new Map();
|
|
43
44
|
this.camera = undefined;
|
|
44
45
|
this.status = undefined;
|
|
46
|
+
// Registered systems run once per frame after all behavior `update`s (see
|
|
47
|
+
// `update`). A system is a plain object with an optional
|
|
48
|
+
// `afterBehaviors(scene, dt)` hook; kits register systems from `systems/*.js`
|
|
49
|
+
// (see makeScene), so the engine core stays decoupled from any specific one.
|
|
50
|
+
this.systems = [];
|
|
45
51
|
this.load(sceneData);
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
// Register a per-frame system (e.g. a physics simulation). Systems step in
|
|
55
|
+
// registration order, after behaviors, every `update`. Returns the system.
|
|
56
|
+
registerSystem(system) {
|
|
57
|
+
this.systems.push(system);
|
|
58
|
+
return system;
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
load(sceneData) {
|
|
49
62
|
this.data = structuredClone(sceneData);
|
|
50
63
|
this.actors = new Map();
|
|
@@ -88,7 +101,9 @@ export class SceneRuntime {
|
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
clone() {
|
|
91
|
-
|
|
104
|
+
// Route through makeScene so the clone gets the same registered systems as a
|
|
105
|
+
// freshly-made runtime.
|
|
106
|
+
return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
serialize() {
|
|
@@ -190,6 +205,12 @@ export class SceneRuntime {
|
|
|
190
205
|
for (const actor of this.getActors()) {
|
|
191
206
|
this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
|
|
192
207
|
}
|
|
208
|
+
// Behaviors have expressed their intent (velocities, forces) for this frame;
|
|
209
|
+
// now let registered systems advance (e.g. physics integrates, writes results
|
|
210
|
+
// back to Layout, and dispatches collision callbacks).
|
|
211
|
+
for (const system of this.systems) {
|
|
212
|
+
system.afterBehaviors?.(this, dt);
|
|
213
|
+
}
|
|
193
214
|
}
|
|
194
215
|
|
|
195
216
|
forEachBehavior(actor, callback) {
|
|
@@ -299,7 +320,12 @@ export class SceneRuntime {
|
|
|
299
320
|
}
|
|
300
321
|
|
|
301
322
|
export function makeScene(sceneData, behaviors, sprites, files) {
|
|
302
|
-
|
|
323
|
+
const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
|
|
324
|
+
// Install any runtime systems the kit provides (systems/*.js) -- no-op in a kit
|
|
325
|
+
// with none. A system's engine is created lazily on first use, so draw-only
|
|
326
|
+
// editor previews (which never call update) stay free.
|
|
327
|
+
for (const install of systemInstallers) install(runtime);
|
|
328
|
+
return runtime;
|
|
303
329
|
}
|
|
304
330
|
|
|
305
331
|
export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Discover per-frame runtime systems from `systems/*.js`. Each such module
|
|
2
|
+
// exports `installSystem(runtime)`, which is called once for every created
|
|
3
|
+
// SceneRuntime (see engine/scene.js `makeScene`) to register itself via
|
|
4
|
+
// `runtime.registerSystem(...)`. A system is a plain object with an optional
|
|
5
|
+
// `afterBehaviors(scene, dt)` hook, run once per frame after all behavior
|
|
6
|
+
// updates. Empty in a kit with no `systems/` dir (e.g. basic-2d); a kit adds a
|
|
7
|
+
// system by dropping a file here -- no edits to the engine required. Symmetric
|
|
8
|
+
// with editors/behaviorRegistry.js.
|
|
9
|
+
const modules = import.meta.glob('../systems/*.js', { eager: true });
|
|
10
|
+
export const systemInstallers = Object.values(modules)
|
|
11
|
+
.map((mod) => mod.installSystem)
|
|
12
|
+
.filter((fn) => typeof fn === 'function');
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
faCode,
|
|
14
14
|
faCodeBranch,
|
|
15
15
|
faEraser,
|
|
16
|
+
faExternalLinkAlt,
|
|
16
17
|
faEyeDropper,
|
|
17
18
|
faFile,
|
|
18
19
|
faFilm,
|
|
@@ -179,6 +180,7 @@ const icons = {
|
|
|
179
180
|
// FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
|
|
180
181
|
'code-fork': faCodeBranch,
|
|
181
182
|
eraser: faEraser,
|
|
183
|
+
external: faExternalLinkAlt,
|
|
182
184
|
eyedropper: faEyeDropper,
|
|
183
185
|
fill: faFillDrip,
|
|
184
186
|
'flip-h': faArrowsAltH,
|
package/kits/basic-2d/main.jsx
CHANGED
|
@@ -15,9 +15,10 @@ if (!root) throw new Error('Missing root element');
|
|
|
15
15
|
// ?file=<path>[&editor=<id>] -> a single rich editor for that file
|
|
16
16
|
const params = new URLSearchParams(window.location.search);
|
|
17
17
|
function pick() {
|
|
18
|
-
|
|
18
|
+
const initialScene = params.get('scene') ?? undefined;
|
|
19
|
+
if (!isEdit()) return <PlayOnly initialScene={initialScene} />;
|
|
19
20
|
const file = params.get('file');
|
|
20
21
|
if (file) return <SingleEditor path={file} editor={params.get('editor') ?? undefined} />;
|
|
21
|
-
return <PlayOnly />;
|
|
22
|
+
return <PlayOnly initialScene={initialScene} />;
|
|
22
23
|
}
|
|
23
24
|
createRoot(root).render(<ErrorBoundary>{pick()}</ErrorBoundary>);
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# physics-2d kit
|
|
2
|
+
|
|
3
|
+
This is the `basic-2d` actor/behavior/scene framework plus a built-in 2D
|
|
4
|
+
**physics** system (matter-js). Everything in `basic-2d` works the same; physics
|
|
5
|
+
adds `RigidBody`, physics fields on `Collider`, a world-gravity scene setting,
|
|
6
|
+
collision callbacks, and ready-made touch-first controls (`Draggable`,
|
|
7
|
+
`Slingshot`, `AnalogStick`). See `## Physics` below.
|
|
8
|
+
|
|
9
|
+
## Welcome message
|
|
10
|
+
|
|
11
|
+
Welcome to an early test of Castle's new engine, with real 2D physics! You're starting with Castle's art and scene editors. Open `cauldron.pxart` or `main.scene` to try them out, or I can start building something for you — a physics toy, a launcher game, a drag-and-fling puzzle, whatever you like.
|
|
12
|
+
|
|
13
|
+
Do you already know what you want to make, or do you want to figure it out together?
|
|
14
|
+
|
|
15
|
+
## Quick reference
|
|
16
|
+
|
|
17
|
+
<!-- Injected into Castle's create assistant every turn; keep this compact and factual. -->
|
|
18
|
+
|
|
19
|
+
- Actor / behavior / scene framework on a fixed 500x700 canvas ("card").
|
|
20
|
+
- Game logic lives in `behaviors/*.jsx` classes. A behavior's `static behaviorName` must match the component key used in scene JSON.
|
|
21
|
+
- Editable screens live in `scenes/*.scene` files (plain JSON). Use a separate scene file per distinct screen, and switch with `scene.loadFromFile('name.scene')`.
|
|
22
|
+
- Every actor is an instance of a **blueprint** (`blueprints/*.scene`) — see `## Blueprints` below. Always author the blueprint file yourself and reference it via `"blueprint"`. Don't write inline actors (full `components`, no `blueprint` field): the editor auto-migrates each one into its own new blueprint file on open, one per actor with no dedup, which litters the deck with junk blueprints.
|
|
23
|
+
- Real game objects and scenery should usually be editable sprites: generate `.pxart` with `npm run draw -- name`, then place it via a `Sprite` component pointing at `drawings/name.pxart`. Dynamic UI/effects stay procedural.
|
|
24
|
+
- This kit is plain JavaScript. Use `.jsx` for files with JSX, `.js` otherwise; do not add TypeScript files or a new build step.
|
|
25
|
+
- After any code, scene, or drawing edit, run `npm run restart`.
|
|
26
|
+
- Space is reserved by the editor for play/stop; do not bind Space to gameplay.
|
|
27
|
+
- Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Their public API is documented below.
|
|
28
|
+
- Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
|
|
29
|
+
|
|
30
|
+
## Scope
|
|
31
|
+
|
|
32
|
+
Write the smallest game that satisfies what the user asked for. No sound, particles, menus, multi-level progression, or visual polish unless they specifically asked for it. A typical behavior is 30–80 lines — if yours is hitting 200, you're over-engineering: cut feel-good extras, fewer fields on props, fewer edge cases, fewer comments. Ship the core loop first; the user can ask for more.
|
|
33
|
+
|
|
34
|
+
## Workflow
|
|
35
|
+
|
|
36
|
+
The deck is already serving when you start (`castle-web init` set that up; see `.castle/serve.json` for the URL). The user is watching that page right now. Your job is to make it interesting incrementally:
|
|
37
|
+
|
|
38
|
+
1. **Build incrementally.** Start with the smallest playable thing (one mechanic, one scene change), `npm run restart`, then add the next piece. Do NOT write the whole game in one shot.
|
|
39
|
+
2. **After every edit:** `npm run restart` (no hot reload). The served page refreshes and the user sees the change.
|
|
40
|
+
3. **Prefer real, editable assets.** For game objects, characters, and scenery, make actual pixel-art sprites and place them as real actors in `scenes/*.scene` — not shapes drawn in code. Real assets let the creator move and re-skin things in the editor and let other creators remix the deck. Make art as `.pxart` via the `draw` command (see **Creating pixel art** below). Data-driven UI (health bars, score/text, HUD gauges) and dynamic things (bullets, particles, effects) are correctly procedural/code — don't force those into sprites.
|
|
41
|
+
4. **Separate scenes per screen.** Use a separate `scenes/*.scene` file for each distinct screen — menu/title, each level, game-over, etc. — not one mega-scene. Each stays independently editable in the editor. Switch at runtime with `scene.loadFromFile('gameover.scene')` (reads the file and transitions) on play / win / level change. NEVER `import` a `.scene` file as a module — scene files are data, not modules; use `scene.readFromFile` / `scene.loadFromFile`.
|
|
42
|
+
|
|
43
|
+
Card size is **500 wide × 700 tall** (origin top-left, +y is down).
|
|
44
|
+
|
|
45
|
+
## Behavior shape
|
|
46
|
+
|
|
47
|
+
A behavior is a class. Minimal contract:
|
|
48
|
+
|
|
49
|
+
```jsx
|
|
50
|
+
// behaviors/MyThing.jsx
|
|
51
|
+
export class MyThing {
|
|
52
|
+
static behaviorName = 'MyThing'; // must match the key used in .scene
|
|
53
|
+
static defaultProps = { speed: 200 };
|
|
54
|
+
|
|
55
|
+
constructor(props) {
|
|
56
|
+
this.props = props;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Called every frame in play mode. dt is seconds.
|
|
60
|
+
update(actor, scene, dt) {
|
|
61
|
+
const layout = actor.components.Layout;
|
|
62
|
+
layout.x += this.props.speed * dt; // mutate component in place
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Optional. Custom drawing (you usually don't need this; use a Sprite
|
|
66
|
+
// component instead). ctx is in card units already.
|
|
67
|
+
draw(actor, scene, ctx) {}
|
|
68
|
+
|
|
69
|
+
// Optional. Return React nodes for game-time HUD. Coordinates are card
|
|
70
|
+
// units. Read state your `update` set; do not start your own loops.
|
|
71
|
+
ui(actor, scene) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A fresh `Behavior` instance is constructed per actor per frame from the actor's component props — DO NOT store per-actor state on `this`. Persist transient state on `actor.runtime` (a free-form object the framework will not serialize) or on the component props themselves.
|
|
78
|
+
|
|
79
|
+
## Scene file (`scenes/main.scene`, plain JSON)
|
|
80
|
+
|
|
81
|
+
A scene is a background plus a list of actors. Every actor is an **instance of a blueprint** (see `## Blueprints`): a `blueprint` path plus sparse `components` overrides — usually just its `Layout` position:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"background": "#1b2030",
|
|
86
|
+
"actors": [
|
|
87
|
+
{ "id": "paddle", "blueprint": "blueprints/paddle.scene", "components": { "Layout": { "x": 200, "y": 650 } } },
|
|
88
|
+
{ "id": "brick-1", "blueprint": "blueprints/brick.scene", "components": { "Layout": { "x": 50, "y": 75 } } },
|
|
89
|
+
{ "id": "brick-2", "blueprint": "blueprints/brick.scene", "components": { "Layout": { "x": 100, "y": 75 } } }
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Rules: every actor needs a unique `id` (any string). An instance's `components` holds ONLY props that differ from its blueprint (merged per-property on top of the template); anything unspecified falls back to the blueprint, then to the behavior's `defaultProps`. Position (`Layout.x`/`y`/`rotation`) always lives on the instance. Omit optional fields (`z`, `rotation`, actor `name`, `tint: '#ffffffff'`, scene `name`) to keep scenes compact, especially when generating many actors.
|
|
95
|
+
|
|
96
|
+
## Blueprints
|
|
97
|
+
|
|
98
|
+
A **blueprint** is a `.scene` file under `blueprints/` whose single actor (`actors[0]`, no `id`) is the template:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"name": "Brick",
|
|
103
|
+
"actors": [
|
|
104
|
+
{
|
|
105
|
+
"components": {
|
|
106
|
+
"Layout": { "width": 50, "height": 25 },
|
|
107
|
+
"Sprite": { "file": "drawings/brick.pxart" },
|
|
108
|
+
"Collider": { "kind": "solid" },
|
|
109
|
+
"Brick": {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Template `components` keys are behavior names (the `static behaviorName`); add a behavior to a kind of actor by adding its key here, remove it by deleting the key. The template almost always has a `Layout` with `width`/`height` (instances supply `x`/`y`).
|
|
117
|
+
|
|
118
|
+
- **Author one blueprint per kind of thing** — `blueprints/<meaningful>.scene` with a real `"name"` — and place instances that reference it. Do this even for a one-off actor: it costs one small file and keeps the deck's blueprint library intentional.
|
|
119
|
+
- Editing a blueprint file changes every instance that doesn't override that prop, across every scene — so one `blueprints/player.scene` referenced from every level stays in sync everywhere. Editing an instance's own `Layout.x`/`y`/`rotation` never touches the blueprint (position/rotation are always instance-local — see `Layout.jsx`'s `propertyMeta`).
|
|
120
|
+
- **Never write inline actors** (full `components`, no `blueprint` field). They aren't a lighter-weight alternative: the editor auto-migrates every such actor into its OWN new blueprint file the first time a human opens the scene — mechanically, one blueprint per actor, no dedup. That migration is a compatibility net for pre-blueprint decks, not an authoring workflow; leaning on it turns 10 inline enemies into 10 junk blueprints.
|
|
121
|
+
- Never invent your own `blueprint` path pointing at a file you didn't also create — a dangling reference resolves to "no template" (the instance's own sparse `components` render alone, missing whatever it expected to inherit).
|
|
122
|
+
|
|
123
|
+
## Built-in behaviors
|
|
124
|
+
|
|
125
|
+
- **Layout** — `{ x, y, width, height, z?, rotation? }`. Every actor needs one. `z` orders draw (low first). `rotation` is degrees about the center.
|
|
126
|
+
- **Sprite** — `{ file: "drawings/foo.pxart", tint?: "#rrggbbaa", playing?: true, tag?: "", mode?: 'cover'|'fit'|'stretch'|'tile', tileSize?: 50 }`. Renders a `.pxart` pixel-art sprite into the Layout box: scaled up preserving the art's aspect ratio to fill the whole box, cropping whatever overflows a mismatched box (`mode: 'cover'`, the default -- CSS object-fit: cover), scaled preserving the art's aspect ratio and centered so the whole sprite stays visible, letterboxing a mismatched box instead of cropping (`mode: 'fit'` -- CSS object-fit: contain), scaled to fill the box exactly and distorting the art when the aspect ratios don't match (`mode: 'stretch'`), or repeated (`mode: 'tile'`). `tint` multiplies; use white (`#ffffffff`) or omit for the original colors. Animated sprites play automatically; set `playing: false` to hold the first frame, or `tag` to play a named animation tag. Tile mode repeats the art at a fixed cell size (`tileSize` card units tall, width scaled by the art's aspect) instead of stretching it across the Layout box.
|
|
127
|
+
- **Missing sprites fall back to a placeholder.** If `file` names a sprite that doesn't exist yet, it renders the fallback `drawings/cauldron.pxart` — so you can give an actor its REAL intended sprite name (`drawings/paddle.pxart`) right away and it shows the placeholder until that file is created. Reference real names from the start; don't wait for the art.
|
|
128
|
+
- **Fill the box, don't distort.** The default `mode: 'cover'` fills the Layout box with the art undistorted, cropping whatever overflows when the box aspect doesn't match the art's — so treat the Layout box like an image frame in a design tool: size it to frame what matters and keep the sprite's important content toward the center, since the edges may be cropped. Keeping an actor's Layout aspect ratio close to its sprite's native aspect ratio minimizes how much gets cropped. When the whole sprite must stay visible (nothing is safe to crop), use `mode: 'fit'`, which preserves the art and letterboxes the mismatched box instead. Avoid `mode: 'stretch'` for pixel art — filling a long/tall box by distorting wrecks the pixels (a brick sprite stretched into a wall ruins the bricks). For long surfaces (walls, floors, platforms), use `mode: 'tile'` on one actor to repeat the art at a fixed cell size instead of stretching or cropping a single sprite.
|
|
129
|
+
- **Collider** — `{ kind: 'solid'|'pickup', mode?: 'auto'|'manual', width, height, offsetX?, offsetY?, debug? }`. Just a rect; `offsetX`/`offsetY` nudge the collider from its default position (so `0,0` is the default). `mode: 'auto'` (the default) fits the rect to the actor's Sprite's opaque pixels, mapped through the Sprite's draw mode (`fit`'s letterboxed dest rect, `stretch`'s full Layout box, `cover`'s filled box clipped to the Layout box; `tile` mode and a missing/unresolved Sprite use the whole Layout box instead). `width`/`height` only apply in `mode: 'manual'` (a rect of that size, centered in the Layout box); they're ignored in `auto`. The framework does NOT auto-resolve collisions for you. Use it as data:
|
|
130
|
+
|
|
131
|
+
```jsx
|
|
132
|
+
for (const other of scene.getActors()) {
|
|
133
|
+
if (other.id === actor.id) continue;
|
|
134
|
+
if (scene.overlaps(actor, other)) {
|
|
135
|
+
/* react */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- **Camera** — `{ target: actorId, followX, followY, roomWidth, roomHeight }`. Place on a dedicated actor; sets `scene.camera` clamped to the room. Omit entirely for a fixed view (no camera = no translation).
|
|
141
|
+
|
|
142
|
+
## Physics
|
|
143
|
+
|
|
144
|
+
This kit simulates 2D physics with matter-js. You get gravity, collisions,
|
|
145
|
+
bouncing, friction, and forces without hand-writing an integrator. The rule of
|
|
146
|
+
thumb: **give an actor a `Collider` to make it collide; add a `RigidBody` to
|
|
147
|
+
make it move.**
|
|
148
|
+
|
|
149
|
+
### Making things physical
|
|
150
|
+
|
|
151
|
+
- **`Collider`** — required for any physics participant. Beyond the base fields
|
|
152
|
+
(`mode`, `width`/`height`, `offsetX`/`offsetY`, `debug`) it has:
|
|
153
|
+
- `shape: 'box' | 'circle'` — the physics shape (default `box`). A circle uses
|
|
154
|
+
`radius`, or half the smaller collider dimension if `radius` is 0.
|
|
155
|
+
- `isTrigger` — a **sensor**: detects overlaps (fires collision callbacks) but
|
|
156
|
+
does **not** block. Use for pickups, goals, zones.
|
|
157
|
+
- `bounciness` — restitution, 0 (dead) to ~1 (very bouncy), can exceed 1.
|
|
158
|
+
- `friction` — surface friction (default 0.1).
|
|
159
|
+
- A `Collider` **without** a `RigidBody` is a static obstacle (walls, floors).
|
|
160
|
+
- **`RigidBody`** — makes an actor move under physics:
|
|
161
|
+
- `bodyType: 'dynamic' | 'static' | 'kinematic'`. `dynamic` = simulated
|
|
162
|
+
(gravity, collisions, forces). `static` = never moves (same as a lone
|
|
163
|
+
Collider). `kinematic` = unpushable, but you move it by writing its Layout.
|
|
164
|
+
- `gravityScale` — multiplies world gravity for this body (`0` = floats).
|
|
165
|
+
- `drag` — linear damping (air resistance). `angularDrag` — spin damping.
|
|
166
|
+
- `freezeRotation` — keep it from rotating (stays upright).
|
|
167
|
+
- `velocityX` / `velocityY` — initial velocity, applied once when play starts.
|
|
168
|
+
|
|
169
|
+
### World gravity
|
|
170
|
+
|
|
171
|
+
Set it per-scene in the `.scene` file: `"physics": { "gravity": 1 }` (matter
|
|
172
|
+
units; `1` is a gentle fall, `0` is zero-g, higher falls faster). `gravityX`
|
|
173
|
+
tilts it sideways. Omit for the default (`1`, downward).
|
|
174
|
+
|
|
175
|
+
### Driving physics from behaviors — `scene.physics`
|
|
176
|
+
|
|
177
|
+
In a behavior's `update`, steer bodies through `scene.physics` (never write
|
|
178
|
+
`Layout` directly for a dynamic body — the sim owns its transform):
|
|
179
|
+
|
|
180
|
+
- `scene.physics.setVelocity(actor, { x, y })` — set velocity (px per fixed
|
|
181
|
+
step; multiply by ~60 for px/s).
|
|
182
|
+
- `scene.physics.applyImpulse(actor, { x, y })` — add to velocity this frame.
|
|
183
|
+
- `scene.physics.applyForce(actor, { x, y })` — add a matter-space force.
|
|
184
|
+
- `scene.physics.getVelocity(actor)` — current velocity `{ x, y }`.
|
|
185
|
+
- `scene.physics.isColliding(a, b)` — are these two actors touching right now?
|
|
186
|
+
|
|
187
|
+
### Collision callbacks
|
|
188
|
+
|
|
189
|
+
A behavior can implement these; the physics system calls them on both actors of
|
|
190
|
+
a contact — every pair, including static-vs-static and moving kinematic bodies
|
|
191
|
+
(and sensor overlaps). For a quick one-off "are these two touching right now?"
|
|
192
|
+
without callbacks, use `scene.physics.isColliding(a, b)`. (`scene.overlaps(a, b)`
|
|
193
|
+
also exists but is a cheaper *bounding-box* test — not shape-accurate; prefer
|
|
194
|
+
the physics calls for physics actors.)
|
|
195
|
+
|
|
196
|
+
```jsx
|
|
197
|
+
export class Coin {
|
|
198
|
+
static behaviorName = 'Coin';
|
|
199
|
+
onCollisionEnter(actor, scene, other) {
|
|
200
|
+
if (other.components.Player) scene.despawnActor(actor.id); // collected
|
|
201
|
+
}
|
|
202
|
+
onCollisionExit(actor, scene, other) {}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Built-in controls (add these behaviors, no code needed)
|
|
207
|
+
|
|
208
|
+
These read input and drive the actor through the physics system, so the actor
|
|
209
|
+
still collides with the world. Each needs a `RigidBody` (dynamic) + `Collider`.
|
|
210
|
+
Controls are **touch/pointer-first** (Castle games are played on mobile first;
|
|
211
|
+
touch degrades to mouse). Keyboard, if added, should only *duplicate* an
|
|
212
|
+
on-screen control -- never be the only way to play.
|
|
213
|
+
|
|
214
|
+
- **`Draggable`** — grab the actor with the pointer and fling it (springy,
|
|
215
|
+
collides). `stiffness`.
|
|
216
|
+
- **`Slingshot`** — press the actor, pull back, release to launch it the
|
|
217
|
+
opposite way (angry-birds), with an aim arrow. `speed`, `maxDrag`.
|
|
218
|
+
- **`AnalogStick`** — on-screen virtual joystick (press anywhere) that drives
|
|
219
|
+
the actor. `speed`, `maxRadius`, `axes`. Best for top-down.
|
|
220
|
+
|
|
221
|
+
### Recipe: a launch-and-bounce game
|
|
222
|
+
|
|
223
|
+
- Ball blueprint: `Layout` (square), `Sprite`, `Collider {shape:'circle',
|
|
224
|
+
bounciness:0.6}`, `RigidBody {bodyType:'dynamic'}`, `Slingshot {}`.
|
|
225
|
+
- Walls/floor blueprint: `Layout`, `Collider {bounciness:0.4}` (no RigidBody →
|
|
226
|
+
static).
|
|
227
|
+
- Goal blueprint: `Collider {isTrigger:true}` + a `Goal` behavior with
|
|
228
|
+
`onCollisionEnter` that scores when `other` is the ball.
|
|
229
|
+
- Scene: set `"physics": { "gravity": 1 }`, place one ball, walls, a goal.
|
|
230
|
+
|
|
231
|
+
### Gotchas
|
|
232
|
+
|
|
233
|
+
- **Fast bodies + thin walls tunnel.** A small body moving faster than a static
|
|
234
|
+
wall is thick (per simulation step, ~a body-speed of `speed*maxDrag` for a
|
|
235
|
+
Slingshot) can pass straight through it — matter-js has no continuous
|
|
236
|
+
collision. Keep boundary walls thick (50px+) and/or cap launch power. The
|
|
237
|
+
demo scene uses 50px walls with a ~20px/step launch cap.
|
|
238
|
+
- **Don't move a dynamic body by writing `Layout`.** The simulation owns a
|
|
239
|
+
dynamic body's transform and overwrites `Layout` every frame — use
|
|
240
|
+
`scene.physics.setVelocity/applyImpulse/applyForce`. Writing `Layout`
|
|
241
|
+
directly is only for `static`/`kinematic` bodies.
|
|
242
|
+
- **Editor vs play.** Physics only steps during play; in the editor actors sit
|
|
243
|
+
where you place them. `RigidBody.velocityX/Y` apply once when play starts.
|
|
244
|
+
|
|
245
|
+
## Adding physics to another kit
|
|
246
|
+
|
|
247
|
+
The physics module (`physics/`) is self-contained and portable. To add it to a
|
|
248
|
+
different kit:
|
|
249
|
+
|
|
250
|
+
1. Copy the `physics/` folder into the kit.
|
|
251
|
+
2. Add `matter-js` to the kit's `package.json` dependencies.
|
|
252
|
+
3. In the kit's `engine/scene.js`: add a systems registry to `SceneRuntime`
|
|
253
|
+
(`this.systems = []`, a `registerSystem(system)` method, and, at the end of
|
|
254
|
+
`update(dt)`, `for (const s of this.systems) s.afterBehaviors?.(this, dt);`),
|
|
255
|
+
then call `installPhysics(runtime)` from `makeScene` (and route `clone()`
|
|
256
|
+
through `makeScene` so clones get it too).
|
|
257
|
+
4. In the kit's `editors/behaviorRegistry`, also glob
|
|
258
|
+
`../physics/behaviors/*.jsx` so the physics behaviors auto-register.
|
|
259
|
+
|
|
260
|
+
The module reuses the kit's `engine/collider.js` `getColliderRect(actor,
|
|
261
|
+
sprites)` for shape geometry, so a kit needs that (both `basic-2d` and this kit
|
|
262
|
+
have it).
|
|
263
|
+
|
|
264
|
+
## Creating pixel art (`.pxart`)
|
|
265
|
+
|
|
266
|
+
Don't hand-write pixel grids. Generate sprites with the `draw` command: **emit a terse svg-rect** — a tiny `<svg viewBox="0 0 16 16">` with **one `<rect>` per pixel** — and pipe it to `npm run draw`. It quantizes every fill to the agent palette and writes a single-frame `drawings/<name>.pxart`.
|
|
267
|
+
|
|
268
|
+
- **Resolution is 16×16.** Use `viewBox="0 0 16 16"` and **one 1×1 `<rect>` per filled pixel** (`width="1" height="1"` at integer `x`/`y`). Leave background pixels out (omitted = transparent). Keep every rect 1×1 — the decoder infers cell size from the smallest rect, so mixing in larger blocks shrinks the whole grid.
|
|
269
|
+
- **Palette is the agent 16-color subset** (a fixed subset of the editor's full Endesga-64 painting palette) — every `fill` must be one of:
|
|
270
|
+
|
|
271
|
+
`#e69c69` `#bf6f4a` `#8a4836` `#391f21` `#891e2b` `#ea323c` `#ffa214` `#ffeb57` `#5ac54f` `#1e6f50` `#134c4c` `#657392` `#c7cfdd` `#ffffff` `#0cf1ff` `#0098dc`
|
|
272
|
+
|
|
273
|
+
(Off-palette fills are snapped to the nearest of these, so stay on-palette to keep what you intend. People editing in the app can paint with the full Endesga-64 palette; only this generation path is constrained to the 16.)
|
|
274
|
+
- **Run it** (sprite name as the arg; stdin is the svg):
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
printf '<svg viewBox="0 0 16 16"><rect x="7" y="4" width="1" height="1" fill="#ffa214"/><rect x="8" y="4" width="1" height="1" fill="#ffa214"/><rect x="7" y="5" width="1" height="1" fill="#0098dc"/><rect x="8" y="5" width="1" height="1" fill="#0098dc"/></svg>' | npm run draw -- ship
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
writes `drawings/ship.pxart`. You can also pass `--from file.svg` instead of stdin. An undecodable svg (no usable rects) errors with a nonzero exit and writes nothing.
|
|
281
|
+
- **After writing, `npm run restart`.** Newly created files aren't in the kit's static glob until a restart; until then the missing-sprite fallback renders the placeholder (so an actor pointing at a not-yet-drawn `.pxart` is still playable).
|
|
282
|
+
|
|
283
|
+
Point a `Sprite` component's `file` at the generated `drawings/<name>.pxart` to put the art on an actor.
|
|
284
|
+
|
|
285
|
+
## SceneRuntime API (what `scene` exposes to behaviors)
|
|
286
|
+
|
|
287
|
+
- `scene.time` — seconds since start.
|
|
288
|
+
- `scene.keys` — `Set` of currently-held KeyboardEvent codes (e.g. `'ArrowLeft'`, `'KeyA'`, `'Space'`). Read in `update`.
|
|
289
|
+
- `scene.pointer` — `{ x, y, down }` in world (card) coordinates, camera-adjusted.
|
|
290
|
+
- `scene.getActor(id)` / `scene.getActors()` (sorted by Layout.z) / `scene.getComponent(actor, name)`.
|
|
291
|
+
- `scene.actorWith('GameController')` / `scene.actorsWith('Brick')` — find one / all actors carrying a given behavior. Prefer these to `getActors().find(a => a.components.X)`.
|
|
292
|
+
- `scene.colliderRect(actorOrId)` — rect from Layout + Collider, or null.
|
|
293
|
+
- `scene.overlaps(a, b)` — true when two actors/ids with Collider overlap.
|
|
294
|
+
- `scene.data` — the live scene data. Mutate `actor.components.X = {...}` to change props.
|
|
295
|
+
- `scene.spawnActor({ components: { Layout: {...}, MyBehavior: {...} } })` — add a new actor at runtime with fully-specified components (no blueprint). Returns the actor (with auto-minted `id` and `runtime = {}`). Use this; don't push to `scene.data.actors` by hand.
|
|
296
|
+
- `scene.spawnFromBlueprint('blueprints/enemy.scene', { components: { Layout: { x, y } } })` — spawn an instance of a blueprint, same merge semantics as a placed instance. Prefer this over `spawnActor` when you're spawning copies of something that has (or should have) a blueprint, e.g. `scene.spawnFromBlueprint(actor.blueprint, { components: { Layout: { x: actor.components.Layout.x, y: actor.components.Layout.y } } })` to spawn another of the same kind as an existing actor.
|
|
297
|
+
- `scene.despawnActor(id)` — remove an actor at runtime. Use this; don't `splice` + `delete` by hand.
|
|
298
|
+
- `scene.status` — string you can set/read for game-state ('playing', 'gameover', ...).
|
|
299
|
+
- `scene.load(sceneData)` — replace the running scene with the given scene data object.
|
|
300
|
+
- `scene.readFromFile(name)` — read and parse a scene file (`'gameover.scene'`, `'levels/2.scene'`), returning its scene data. Use this instead of importing a `.scene` file.
|
|
301
|
+
- `scene.loadFromFile(name)` — read a scene file and transition the running scene to it. The way to switch screens/levels and to restart, e.g. `scene.loadFromFile('main.scene')`.
|
|
302
|
+
- `actor.runtime` — per-instance scratchpad for transient state across frames (e.g. velocity, trail history). Not serialized.
|
|
303
|
+
|
|
304
|
+
## Input shortcuts
|
|
305
|
+
|
|
306
|
+
```jsx
|
|
307
|
+
if (scene.keys.has('ArrowLeft')) layout.x -= speed * dt;
|
|
308
|
+
if (scene.keys.has('ArrowRight')) layout.x += speed * dt;
|
|
309
|
+
if (scene.keys.has('KeyX')) /* launch ball */ ;
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Space is reserved** — the editor binds it to the play/stop toggle, so don't bind Space to a gameplay action (jump / shoot / launch / ...). Use arrows, WASD, letter keys, or on-screen buttons instead.
|
|
313
|
+
|
|
314
|
+
For HUD text use a behavior's `ui` hook (returns React); for in-world text or shapes, draw with `ctx` from `draw`.
|
|
315
|
+
|
|
316
|
+
## Common breakout-shaped recipe (sketch)
|
|
317
|
+
|
|
318
|
+
- `Paddle` behavior: read keys, clamp x to `[0, 500 - layout.width]`.
|
|
319
|
+
- `Ball` behavior: store `vx, vy` on `actor.runtime`; integrate; bounce off wall edges (`x<0`, `x+w>500`, `y<0`); on `scene.overlaps(actor, paddle)`, flip `vy`; for each `brick` in `scene.actorsWith('Brick')` check `scene.overlaps(actor, brick)` → flip `vy` and `scene.despawnActor(brick.id)`; if `y > 700` lose a life.
|
|
320
|
+
- `Brick` behavior: typically just a marker — `kind: 'solid'` collider is enough. State (hit count) goes on `actor.runtime` or the brick's own props.
|
|
321
|
+
- `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `ui()`.
|
|
322
|
+
|
|
323
|
+
## Don't
|
|
324
|
+
|
|
325
|
+
- Don't `console.log` in tight loops — flood the serve log.
|
|
326
|
+
- Don't keep per-actor state on the behavior class instance; it's recreated each frame. Use `actor.runtime` or component props.
|
|
327
|
+
- Don't try to import from `editors/`; behaviors run in the play runtime too.
|
|
328
|
+
- Don't add types or `.ts`/`.tsx` files. This kit is JavaScript.
|
|
329
|
+
- Don't add a build step or change `vite.config.js` for a game — it's configured for you.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cardSize } from '../engine/scene';
|
|
2
|
+
|
|
3
|
+
// Follow camera: keeps the target actor centered in the viewport, clamped to
|
|
4
|
+
// the room bounds so the empty area past the room edges never scrolls in.
|
|
5
|
+
export class Camera {
|
|
6
|
+
static behaviorName = 'Camera';
|
|
7
|
+
|
|
8
|
+
static defaultProps = {
|
|
9
|
+
target: '',
|
|
10
|
+
followX: true,
|
|
11
|
+
followY: true,
|
|
12
|
+
roomWidth: 900,
|
|
13
|
+
roomHeight: 1100,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
constructor(props) {
|
|
17
|
+
this.props = props;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
update(_actor, scene) {
|
|
21
|
+
const target = this.props.target ? scene.getActor(this.props.target) : undefined;
|
|
22
|
+
const targetLayout = target?.components.Layout;
|
|
23
|
+
if (!targetLayout) return;
|
|
24
|
+
|
|
25
|
+
const centerX = targetLayout.x + targetLayout.width / 2;
|
|
26
|
+
const centerY = targetLayout.y + targetLayout.height / 2;
|
|
27
|
+
const desiredX = centerX - cardSize.width / 2;
|
|
28
|
+
const desiredY = centerY - cardSize.height / 2;
|
|
29
|
+
|
|
30
|
+
scene.camera = {
|
|
31
|
+
x: this.props.followX
|
|
32
|
+
? clamp(desiredX, 0, this.props.roomWidth - cardSize.width)
|
|
33
|
+
: 0,
|
|
34
|
+
y: this.props.followY
|
|
35
|
+
? clamp(desiredY, 0, this.props.roomHeight - cardSize.height)
|
|
36
|
+
: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clamp(value, min, max) {
|
|
42
|
+
return Math.max(min, Math.min(Math.max(min, max), value));
|
|
43
|
+
}
|