cubeforge 0.0.7 → 0.0.9

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.
@@ -0,0 +1,245 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState, useContext } from 'react';
3
+ import { createTransform, createScript } from '@cubeforge/core';
4
+ import { createSprite } from '@cubeforge/renderer';
5
+ import { createRigidBody, createBoxCollider } from '@cubeforge/physics';
6
+ import { EngineContext } from '../context';
7
+ const animatedTiles = new Map();
8
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
9
+ function getProperty(props, name) {
10
+ return props?.find(p => p.name === name)?.value;
11
+ }
12
+ function matchesLayerName(layer, name) {
13
+ return (layer.name === name ||
14
+ layer.name.toLowerCase() === name.toLowerCase());
15
+ }
16
+ function isCollisionLayer(layer, collisionLayer) {
17
+ return (matchesLayerName(layer, collisionLayer) ||
18
+ getProperty(layer.properties, 'collision') === true);
19
+ }
20
+ function isTriggerLayer(layer, triggerLayer) {
21
+ return (matchesLayerName(layer, triggerLayer) ||
22
+ getProperty(layer.properties, 'trigger') === true);
23
+ }
24
+ export function Tilemap({ src, onSpawnObject, layerFilter, zIndex = 0, collisionLayer = 'collision', triggerLayer: triggerLayerName = 'triggers', onTileProperty, }) {
25
+ const engine = useContext(EngineContext);
26
+ const [spawnedNodes, setSpawnedNodes] = useState([]);
27
+ useEffect(() => {
28
+ if (!engine)
29
+ return;
30
+ const createdEntities = [];
31
+ async function load() {
32
+ let mapData;
33
+ try {
34
+ const res = await fetch(src);
35
+ if (!res.ok)
36
+ throw new Error(`HTTP ${res.status}`);
37
+ mapData = await res.json();
38
+ }
39
+ catch (err) {
40
+ console.warn(`[Cubeforge] Tilemap: failed to load "${src}":`, err);
41
+ return;
42
+ }
43
+ const { tilewidth, tileheight, tilesets } = mapData;
44
+ // Resolve the tileset for a given GID, plus the local tile id within it
45
+ function resolveTileset(gid) {
46
+ let tileset = null;
47
+ for (let i = tilesets.length - 1; i >= 0; i--) {
48
+ if (gid >= tilesets[i].firstgid) {
49
+ tileset = tilesets[i];
50
+ break;
51
+ }
52
+ }
53
+ if (!tileset)
54
+ return null;
55
+ return { tileset, localId: gid - tileset.firstgid };
56
+ }
57
+ // Build tileset image map: GID → { imageSrc, sx, sy, sw, sh }
58
+ function getTileFrame(gid) {
59
+ const resolved = resolveTileset(gid);
60
+ if (!resolved)
61
+ return null;
62
+ const { tileset, localId } = resolved;
63
+ const col = localId % tileset.columns;
64
+ const row = Math.floor(localId / tileset.columns);
65
+ const sx = tileset.margin + col * (tileset.tilewidth + tileset.spacing);
66
+ const sy = tileset.margin + row * (tileset.tileheight + tileset.spacing);
67
+ // Resolve image path relative to the map src
68
+ const base = src.substring(0, src.lastIndexOf('/') + 1);
69
+ const imageSrc = tileset.image.startsWith('/') ? tileset.image : base + tileset.image;
70
+ return { imageSrc, sx, sy, sw: tileset.tilewidth, sh: tileset.tileheight };
71
+ }
72
+ // Look up per-tile data (animation, properties) from the tileset
73
+ function getTileData(gid) {
74
+ const resolved = resolveTileset(gid);
75
+ if (!resolved)
76
+ return null;
77
+ const { tileset, localId } = resolved;
78
+ return tileset.tiles?.find(t => t.id === localId) ?? null;
79
+ }
80
+ // Compute the frame region for a local tile id within a tileset
81
+ function getFrameForLocalId(tileset, localId) {
82
+ const col = localId % tileset.columns;
83
+ const row = Math.floor(localId / tileset.columns);
84
+ const sx = tileset.margin + col * (tileset.tilewidth + tileset.spacing);
85
+ const sy = tileset.margin + row * (tileset.tileheight + tileset.spacing);
86
+ const base = src.substring(0, src.lastIndexOf('/') + 1);
87
+ const imageSrc = tileset.image.startsWith('/') ? tileset.image : base + tileset.image;
88
+ return { imageSrc, sx, sy, sw: tileset.tilewidth, sh: tileset.tileheight };
89
+ }
90
+ const objectNodes = [];
91
+ for (const layer of mapData.layers) {
92
+ if (layerFilter && !layerFilter(layer))
93
+ continue;
94
+ if (!layer.visible)
95
+ continue;
96
+ if (layer.type === 'tilelayer' && layer.data) {
97
+ const collision = isCollisionLayer(layer, collisionLayer);
98
+ const trigger = !collision && isTriggerLayer(layer, triggerLayerName);
99
+ if (collision || trigger) {
100
+ // Merge adjacent filled tiles in each row into single wide colliders
101
+ for (let row = 0; row < mapData.height; row++) {
102
+ let col = 0;
103
+ while (col < mapData.width) {
104
+ const i = row * mapData.width + col;
105
+ const gid = layer.data[i];
106
+ if (gid === 0) {
107
+ col++;
108
+ continue;
109
+ }
110
+ // Start of a run — extend right while tiles are filled
111
+ let runLength = 1;
112
+ while (col + runLength < mapData.width &&
113
+ layer.data[row * mapData.width + col + runLength] !== 0) {
114
+ runLength++;
115
+ }
116
+ const runWidth = runLength * tilewidth;
117
+ const x = col * tilewidth + runWidth / 2;
118
+ const y = row * tileheight + tileheight / 2;
119
+ const eid = engine.ecs.createEntity();
120
+ createdEntities.push(eid);
121
+ engine.ecs.addComponent(eid, createTransform(x, y));
122
+ if (collision) {
123
+ // Invisible solid collider spanning the entire run
124
+ engine.ecs.addComponent(eid, createRigidBody({ isStatic: true }));
125
+ engine.ecs.addComponent(eid, createBoxCollider(runWidth, tileheight));
126
+ }
127
+ else {
128
+ // Invisible trigger collider spanning the entire run
129
+ engine.ecs.addComponent(eid, createBoxCollider(runWidth, tileheight, { isTrigger: true }));
130
+ }
131
+ col += runLength;
132
+ }
133
+ }
134
+ }
135
+ else {
136
+ // Visual tiles — render per tile with sprites
137
+ for (let i = 0; i < layer.data.length; i++) {
138
+ const gid = layer.data[i];
139
+ if (gid === 0)
140
+ continue;
141
+ const col = i % mapData.width;
142
+ const row = Math.floor(i / mapData.width);
143
+ // Tile center position
144
+ const x = col * tilewidth + tilewidth / 2;
145
+ const y = row * tileheight + tileheight / 2;
146
+ const eid = engine.ecs.createEntity();
147
+ createdEntities.push(eid);
148
+ engine.ecs.addComponent(eid, createTransform(x, y));
149
+ // Visual tile — load image and set frame
150
+ const frame = getTileFrame(gid);
151
+ const sprite = createSprite({ width: tilewidth, height: tileheight, color: '#888', zIndex });
152
+ if (frame) {
153
+ sprite.frame = { sx: frame.sx, sy: frame.sy, sw: frame.sw, sh: frame.sh };
154
+ engine.assets.loadImage(frame.imageSrc)
155
+ .then((img) => {
156
+ const s = engine.ecs.getComponent(eid, 'Sprite');
157
+ if (s)
158
+ s.image = img;
159
+ })
160
+ .catch(() => { });
161
+ }
162
+ engine.ecs.addComponent(eid, sprite);
163
+ // Check for animated tile
164
+ const tileData = getTileData(gid);
165
+ if (tileData?.animation && tileData.animation.length > 0) {
166
+ const resolved = resolveTileset(gid);
167
+ const frames = tileData.animation.map(a => a.tileid);
168
+ const durations = tileData.animation.map(a => a.duration / 1000); // ms → seconds
169
+ const state = { frames, durations, timer: 0, currentFrame: 0 };
170
+ animatedTiles.set(eid, state);
171
+ // Pre-load first frame image (tileset image already loading above)
172
+ // Set initial frame region from the first animation frame
173
+ const firstFrameRegion = getFrameForLocalId(resolved.tileset, frames[0]);
174
+ engine.assets.loadImage(firstFrameRegion.imageSrc)
175
+ .then((img) => {
176
+ const s = engine.ecs.getComponent(eid, 'Sprite');
177
+ if (s) {
178
+ s.image = img;
179
+ s.frame = {
180
+ sx: firstFrameRegion.sx,
181
+ sy: firstFrameRegion.sy,
182
+ sw: firstFrameRegion.sw,
183
+ sh: firstFrameRegion.sh,
184
+ };
185
+ }
186
+ })
187
+ .catch(() => { });
188
+ engine.ecs.addComponent(eid, createScript((_eid, world, _input, dt) => {
189
+ const animState = animatedTiles.get(_eid);
190
+ if (!animState)
191
+ return;
192
+ animState.timer += dt;
193
+ const currentDuration = animState.durations[animState.currentFrame];
194
+ if (animState.timer >= currentDuration) {
195
+ animState.timer -= currentDuration;
196
+ animState.currentFrame = (animState.currentFrame + 1) % animState.frames.length;
197
+ const nextLocalId = animState.frames[animState.currentFrame];
198
+ const resolvedTs = resolveTileset(gid);
199
+ if (!resolvedTs)
200
+ return;
201
+ const region = getFrameForLocalId(resolvedTs.tileset, nextLocalId);
202
+ const s = world.getComponent(_eid, 'Sprite');
203
+ if (s) {
204
+ s.frame = { sx: region.sx, sy: region.sy, sw: region.sw, sh: region.sh };
205
+ }
206
+ }
207
+ }));
208
+ }
209
+ // Fire onTileProperty callback if tile has custom properties
210
+ if (onTileProperty && tileData?.properties && tileData.properties.length > 0) {
211
+ const propsMap = {};
212
+ for (const p of tileData.properties) {
213
+ propsMap[p.name] = p.value;
214
+ }
215
+ onTileProperty(gid, propsMap, x, y);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ else if (layer.type === 'objectgroup' && layer.objects) {
221
+ if (onSpawnObject) {
222
+ for (const obj of layer.objects) {
223
+ const node = onSpawnObject(obj, layer);
224
+ if (node)
225
+ objectNodes.push(node);
226
+ }
227
+ }
228
+ }
229
+ }
230
+ setSpawnedNodes(objectNodes);
231
+ }
232
+ load();
233
+ return () => {
234
+ for (const eid of createdEntities) {
235
+ animatedTiles.delete(eid);
236
+ if (engine.ecs.hasEntity(eid))
237
+ engine.ecs.destroyEntity(eid);
238
+ }
239
+ };
240
+ // eslint-disable-next-line react-hooks/exhaustive-deps
241
+ }, [src]);
242
+ if (spawnedNodes.length === 0)
243
+ return null;
244
+ return _jsx(_Fragment, { children: spawnedNodes });
245
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useContext } from 'react';
2
+ import { createTransform } from '@cubeforge/core';
3
+ import { EngineContext, EntityContext } from '../context';
4
+ export function Transform({ x = 0, y = 0, rotation = 0, scaleX = 1, scaleY = 1 }) {
5
+ const engine = useContext(EngineContext);
6
+ const entityId = useContext(EntityContext);
7
+ useEffect(() => {
8
+ engine.ecs.addComponent(entityId, createTransform(x, y, rotation, scaleX, scaleY));
9
+ return () => engine.ecs.removeComponent(entityId, 'Transform');
10
+ // eslint-disable-next-line react-hooks/exhaustive-deps
11
+ }, []);
12
+ // Sync prop changes to component data
13
+ useEffect(() => {
14
+ const comp = engine.ecs.getComponent(entityId, 'Transform');
15
+ if (comp) {
16
+ comp.x = x;
17
+ comp.y = y;
18
+ comp.rotation = rotation;
19
+ comp.scaleX = scaleX;
20
+ comp.scaleY = scaleY;
21
+ }
22
+ }, [x, y, rotation, scaleX, scaleY, engine, entityId]);
23
+ return null;
24
+ }
@@ -0,0 +1,28 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useContext } from 'react';
3
+ import { EngineContext } from '../context';
4
+ export function World({ gravity, background = '#1a1a2e', children }) {
5
+ const engine = useContext(EngineContext);
6
+ useEffect(() => {
7
+ if (!engine)
8
+ return;
9
+ if (gravity !== undefined)
10
+ engine.physics.setGravity(gravity);
11
+ }, [gravity, engine]);
12
+ useEffect(() => {
13
+ if (!engine)
14
+ return;
15
+ // Propagate background to the camera component (or store it for renderer)
16
+ // The camera component stores background — if no camera exists, fill canvas directly
17
+ const camId = engine.ecs.queryOne('Camera2D');
18
+ if (camId !== undefined) {
19
+ const cam = engine.ecs.getComponent(camId, 'Camera2D');
20
+ if (cam)
21
+ cam.background = background;
22
+ }
23
+ else {
24
+ engine.canvas.style.background = background;
25
+ }
26
+ }, [background, engine]);
27
+ return _jsx(_Fragment, { children: children });
28
+ }
@@ -0,0 +1,27 @@
1
+ export const PARTICLE_PRESETS = {
2
+ explosion: {
3
+ rate: 60, speed: 200, spread: Math.PI * 2, angle: 0,
4
+ particleLife: 0.5, particleSize: 6, color: '#ff6b35',
5
+ gravity: 300, maxParticles: 80,
6
+ },
7
+ spark: {
8
+ rate: 40, speed: 150, spread: Math.PI * 2, angle: 0,
9
+ particleLife: 0.3, particleSize: 3, color: '#ffd54f',
10
+ gravity: 400, maxParticles: 50,
11
+ },
12
+ smoke: {
13
+ rate: 15, speed: 30, spread: 0.5, angle: -Math.PI / 2,
14
+ particleLife: 1.2, particleSize: 10, color: '#90a4ae',
15
+ gravity: -20, maxParticles: 40,
16
+ },
17
+ coinPickup: {
18
+ rate: 30, speed: 80, spread: Math.PI * 2, angle: -Math.PI / 2,
19
+ particleLife: 0.4, particleSize: 4, color: '#ffd700',
20
+ gravity: 200, maxParticles: 20,
21
+ },
22
+ jumpDust: {
23
+ rate: 25, speed: 60, spread: Math.PI, angle: Math.PI / 2,
24
+ particleLife: 0.3, particleSize: 5, color: '#b0bec5',
25
+ gravity: 80, maxParticles: 20,
26
+ },
27
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Helper to build an atlas from a grid spritesheet.
3
+ * columns = number of frames per row.
4
+ * names = frame names in row-major order.
5
+ */
6
+ export function createAtlas(names, _columns) {
7
+ const atlas = {};
8
+ names.forEach((name, i) => { atlas[name] = i; });
9
+ return atlas;
10
+ }
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+ export const EngineContext = createContext(null);
3
+ export const EntityContext = createContext(null);
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { EntityContext } from '../context';
3
+ export function useEntity() {
4
+ const id = useContext(EntityContext);
5
+ if (id === null)
6
+ throw new Error('useEntity must be used inside <Entity>');
7
+ return id;
8
+ }
@@ -0,0 +1,15 @@
1
+ import { useContext, useEffect } from 'react';
2
+ import { EngineContext } from '../context';
3
+ export function useEvents() {
4
+ const engine = useContext(EngineContext);
5
+ if (!engine)
6
+ throw new Error('useEvents must be used inside <Game>');
7
+ return engine.events;
8
+ }
9
+ export function useEvent(event, handler) {
10
+ const events = useEvents();
11
+ useEffect(() => {
12
+ return events.on(event, handler);
13
+ // eslint-disable-next-line react-hooks/exhaustive-deps
14
+ }, [events, event]);
15
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { EngineContext } from '../context';
3
+ export function useGame() {
4
+ const engine = useContext(EngineContext);
5
+ if (!engine)
6
+ throw new Error('useGame must be used inside <Game>');
7
+ return engine;
8
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react';
2
+ import { EngineContext } from '../context';
3
+ export function useInput() {
4
+ const engine = useContext(EngineContext);
5
+ if (!engine)
6
+ throw new Error('useInput must be used inside <Game>');
7
+ return engine.input;
8
+ }
@@ -0,0 +1,82 @@
1
+ import { useContext, useEffect } from 'react';
2
+ import { createScript } from '@cubeforge/core';
3
+ import { EngineContext } from '../context';
4
+ /**
5
+ * Attaches platformer movement (WASD/Arrows + Space/Up to jump) to an entity.
6
+ * The entity must already have a RigidBody component.
7
+ *
8
+ * @example
9
+ * function Player() {
10
+ * const id = useEntity()
11
+ * usePlatformerController(id, { speed: 220, maxJumps: 2 })
12
+ * return (
13
+ * <Entity id="player">
14
+ * <Transform x={100} y={300} />
15
+ * <Sprite width={28} height={40} color="#4fc3f7" />
16
+ * <RigidBody />
17
+ * <BoxCollider width={26} height={40} />
18
+ * </Entity>
19
+ * )
20
+ * }
21
+ */
22
+ export function usePlatformerController(entityId, opts = {}) {
23
+ const engine = useContext(EngineContext);
24
+ const { speed = 200, jumpForce = -500, maxJumps = 1, coyoteTime = 0.08, jumpBuffer = 0.08, } = opts;
25
+ useEffect(() => {
26
+ const state = { coyoteTimer: 0, jumpBuffer: 0, jumpsLeft: maxJumps };
27
+ const updateFn = (id, world, input, dt) => {
28
+ if (!world.hasEntity(id))
29
+ return;
30
+ const rb = world.getComponent(id, 'RigidBody');
31
+ if (!rb)
32
+ return;
33
+ // Ground tracking
34
+ if (rb.onGround) {
35
+ state.coyoteTimer = coyoteTime;
36
+ state.jumpsLeft = maxJumps;
37
+ }
38
+ else
39
+ state.coyoteTimer = Math.max(0, state.coyoteTimer - dt);
40
+ // Jump buffer
41
+ const jumpPressed = input.isPressed('Space') || input.isPressed('ArrowUp') ||
42
+ input.isPressed('KeyW') || input.isPressed('w');
43
+ if (jumpPressed)
44
+ state.jumpBuffer = jumpBuffer;
45
+ else
46
+ state.jumpBuffer = Math.max(0, state.jumpBuffer - dt);
47
+ // Horizontal movement
48
+ const left = input.isDown('ArrowLeft') || input.isDown('KeyA') || input.isDown('a');
49
+ const right = input.isDown('ArrowRight') || input.isDown('KeyD') || input.isDown('d');
50
+ if (left)
51
+ rb.vx = -speed;
52
+ else if (right)
53
+ rb.vx = speed;
54
+ else
55
+ rb.vx *= rb.onGround ? 0.6 : 0.92;
56
+ // Sprite flip
57
+ const sprite = world.getComponent(id, 'Sprite');
58
+ if (sprite) {
59
+ if (left)
60
+ sprite.flipX = true;
61
+ if (right)
62
+ sprite.flipX = false;
63
+ }
64
+ // Jump
65
+ const canJump = state.coyoteTimer > 0 || state.jumpsLeft > 0;
66
+ if (state.jumpBuffer > 0 && canJump) {
67
+ rb.vy = jumpForce;
68
+ state.jumpsLeft = Math.max(0, state.jumpsLeft - 1);
69
+ state.coyoteTimer = 0;
70
+ state.jumpBuffer = 0;
71
+ }
72
+ // Variable jump height — release early to cut arc
73
+ const jumpHeld = input.isDown('Space') || input.isDown('ArrowUp') ||
74
+ input.isDown('KeyW') || input.isDown('w');
75
+ if (!jumpHeld && rb.vy < -120)
76
+ rb.vy += 800 * dt;
77
+ };
78
+ engine.ecs.addComponent(entityId, createScript(updateFn));
79
+ return () => engine.ecs.removeComponent(entityId, 'Script');
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, []);
82
+ }
@@ -0,0 +1,46 @@
1
+ import { useContext, useEffect } from 'react';
2
+ import { createScript } from '@cubeforge/core';
3
+ import { EngineContext } from '../context';
4
+ /**
5
+ * Attaches 4-directional top-down movement (WASD/Arrows) to an entity.
6
+ * The entity must have a RigidBody with gravityScale=0 for top-down games.
7
+ *
8
+ * @example
9
+ * <Entity id="player">
10
+ * <Transform x={100} y={100} />
11
+ * <Sprite width={24} height={24} color="#4fc3f7" />
12
+ * <RigidBody gravityScale={0} />
13
+ * <BoxCollider width={24} height={24} />
14
+ * </Entity>
15
+ * // In a Script or parent component:
16
+ * useTopDownMovement(playerId, { speed: 180 })
17
+ */
18
+ export function useTopDownMovement(entityId, opts = {}) {
19
+ const engine = useContext(EngineContext);
20
+ const { speed = 200, normalizeDiagonal = true } = opts;
21
+ useEffect(() => {
22
+ const updateFn = (id, world, input) => {
23
+ if (!world.hasEntity(id))
24
+ return;
25
+ const rb = world.getComponent(id, 'RigidBody');
26
+ if (!rb)
27
+ return;
28
+ const left = (input.isDown('ArrowLeft') || input.isDown('KeyA') || input.isDown('a')) ? -1 : 0;
29
+ const right = (input.isDown('ArrowRight') || input.isDown('KeyD') || input.isDown('d')) ? 1 : 0;
30
+ const up = (input.isDown('ArrowUp') || input.isDown('KeyW') || input.isDown('w')) ? -1 : 0;
31
+ const down = (input.isDown('ArrowDown') || input.isDown('KeyS') || input.isDown('s')) ? 1 : 0;
32
+ let dx = left + right;
33
+ let dy = up + down;
34
+ if (normalizeDiagonal && dx !== 0 && dy !== 0) {
35
+ const len = Math.sqrt(dx * dx + dy * dy);
36
+ dx /= len;
37
+ dy /= len;
38
+ }
39
+ rb.vx = dx * speed;
40
+ rb.vy = dy * speed;
41
+ };
42
+ engine.ecs.addComponent(entityId, createScript(updateFn));
43
+ return () => engine.ecs.removeComponent(entityId, 'Script');
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, []);
46
+ }
package/dist/index.d.ts CHANGED
@@ -44,7 +44,9 @@ export type { RaycastHit } from '@cubeforge/physics';
44
44
  export type { InputManager, ActionBindings, InputMap } from '@cubeforge/input';
45
45
  export { createInputMap } from '@cubeforge/input';
46
46
  export type { BoundInputMap } from './hooks/useInputMap';
47
- export type { TransformComponent } from '@cubeforge/core';
47
+ export type { TransformComponent, Component } from '@cubeforge/core';
48
+ export { createTransform, createTag } from '@cubeforge/core';
49
+ export { createSprite } from '@cubeforge/renderer';
48
50
  export type { RigidBodyComponent } from '@cubeforge/physics';
49
51
  export type { BoxColliderComponent } from '@cubeforge/physics';
50
52
  export type { CircleColliderComponent } from '@cubeforge/physics';