cubeforge 0.0.1
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/components/Animation.d.ts +12 -0
- package/dist/components/Animation.js +32 -0
- package/dist/components/BoxCollider.d.ts +10 -0
- package/dist/components/BoxCollider.js +21 -0
- package/dist/components/Camera2D.d.ts +20 -0
- package/dist/components/Camera2D.js +33 -0
- package/dist/components/Checkpoint.d.ts +18 -0
- package/dist/components/Checkpoint.js +36 -0
- package/dist/components/Entity.d.ts +10 -0
- package/dist/components/Entity.js +29 -0
- package/dist/components/Game.d.ts +28 -0
- package/dist/components/Game.js +93 -0
- package/dist/components/MovingPlatform.d.ts +22 -0
- package/dist/components/MovingPlatform.js +28 -0
- package/dist/components/ParallaxLayer.d.ts +28 -0
- package/dist/components/ParallaxLayer.js +51 -0
- package/dist/components/ParticleEmitter.d.ts +26 -0
- package/dist/components/ParticleEmitter.js +44 -0
- package/dist/components/RigidBody.d.ts +11 -0
- package/dist/components/RigidBody.js +13 -0
- package/dist/components/ScreenFlash.d.ts +4 -0
- package/dist/components/ScreenFlash.js +34 -0
- package/dist/components/Script.d.ts +11 -0
- package/dist/components/Script.js +20 -0
- package/dist/components/Sprite.d.ts +22 -0
- package/dist/components/Sprite.js +49 -0
- package/dist/components/SquashStretch.d.ts +8 -0
- package/dist/components/SquashStretch.js +18 -0
- package/dist/components/Tilemap.d.ts +58 -0
- package/dist/components/Tilemap.js +245 -0
- package/dist/components/Transform.d.ts +9 -0
- package/dist/components/Transform.js +24 -0
- package/dist/components/World.d.ts +10 -0
- package/dist/components/World.js +28 -0
- package/dist/components/particlePresets.d.ts +13 -0
- package/dist/components/particlePresets.js +27 -0
- package/dist/components/spriteAtlas.d.ts +8 -0
- package/dist/components/spriteAtlas.js +10 -0
- package/dist/context.d.ts +19 -0
- package/dist/context.js +3 -0
- package/dist/hooks/useEntity.d.ts +2 -0
- package/dist/hooks/useEntity.js +8 -0
- package/dist/hooks/useEvents.d.ts +3 -0
- package/dist/hooks/useEvents.js +15 -0
- package/dist/hooks/useGame.d.ts +2 -0
- package/dist/hooks/useGame.js +8 -0
- package/dist/hooks/useInput.d.ts +2 -0
- package/dist/hooks/useInput.js +8 -0
- package/dist/hooks/usePlatformerController.d.ts +32 -0
- package/dist/hooks/usePlatformerController.js +82 -0
- package/dist/hooks/useTopDownMovement.d.ts +22 -0
- package/dist/hooks/useTopDownMovement.js +46 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +27 -0
- package/dist/systems/debugSystem.d.ts +10 -0
- package/dist/systems/debugSystem.js +104 -0
- package/package.json +37 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { createScript } from '@cubeforge/core';
|
|
3
|
+
import { EngineContext, EntityContext } from '../context';
|
|
4
|
+
export function Script({ init, update }) {
|
|
5
|
+
const engine = useContext(EngineContext);
|
|
6
|
+
const entityId = useContext(EntityContext);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (init) {
|
|
9
|
+
try {
|
|
10
|
+
init(entityId, engine.ecs);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
console.error(`[Cubeforge] Script init error on entity ${entityId}:`, err);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
engine.ecs.addComponent(entityId, createScript(update));
|
|
17
|
+
return () => engine.ecs.removeComponent(entityId, 'Script');
|
|
18
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SpriteAtlas } from './spriteAtlas';
|
|
2
|
+
interface SpriteProps {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
color?: string;
|
|
6
|
+
src?: string;
|
|
7
|
+
offsetX?: number;
|
|
8
|
+
offsetY?: number;
|
|
9
|
+
zIndex?: number;
|
|
10
|
+
visible?: boolean;
|
|
11
|
+
flipX?: boolean;
|
|
12
|
+
anchorX?: number;
|
|
13
|
+
anchorY?: number;
|
|
14
|
+
frameIndex?: number;
|
|
15
|
+
frameWidth?: number;
|
|
16
|
+
frameHeight?: number;
|
|
17
|
+
frameColumns?: number;
|
|
18
|
+
atlas?: SpriteAtlas;
|
|
19
|
+
frame?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function Sprite({ width, height, color, src, offsetX, offsetY, zIndex, visible, flipX, anchorX, anchorY, frameIndex, frameWidth, frameHeight, frameColumns, atlas, frame, }: SpriteProps): null;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { createSprite } from '@cubeforge/renderer';
|
|
3
|
+
import { EngineContext, EntityContext } from '../context';
|
|
4
|
+
export function Sprite({ width, height, color = '#ffffff', src, offsetX = 0, offsetY = 0, zIndex = 0, visible = true, flipX = false, anchorX = 0.5, anchorY = 0.5, frameIndex = 0, frameWidth, frameHeight, frameColumns, atlas, frame, }) {
|
|
5
|
+
const resolvedFrameIndex = (atlas && frame != null) ? (atlas[frame] ?? 0) : frameIndex;
|
|
6
|
+
const engine = useContext(EngineContext);
|
|
7
|
+
const entityId = useContext(EntityContext);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const comp = createSprite({
|
|
10
|
+
width,
|
|
11
|
+
height,
|
|
12
|
+
color,
|
|
13
|
+
src,
|
|
14
|
+
offsetX,
|
|
15
|
+
offsetY,
|
|
16
|
+
zIndex,
|
|
17
|
+
visible,
|
|
18
|
+
flipX,
|
|
19
|
+
anchorX,
|
|
20
|
+
anchorY,
|
|
21
|
+
frameIndex: resolvedFrameIndex,
|
|
22
|
+
frameWidth,
|
|
23
|
+
frameHeight,
|
|
24
|
+
frameColumns,
|
|
25
|
+
});
|
|
26
|
+
engine.ecs.addComponent(entityId, comp);
|
|
27
|
+
if (src) {
|
|
28
|
+
engine.assets.loadImage(src).then((img) => {
|
|
29
|
+
const c = engine.ecs.getComponent(entityId, 'Sprite');
|
|
30
|
+
if (c)
|
|
31
|
+
c.image = img;
|
|
32
|
+
}).catch(console.error);
|
|
33
|
+
}
|
|
34
|
+
return () => engine.ecs.removeComponent(entityId, 'Sprite');
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
}, []);
|
|
37
|
+
// Sync mutable props
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const comp = engine.ecs.getComponent(entityId, 'Sprite');
|
|
40
|
+
if (!comp)
|
|
41
|
+
return;
|
|
42
|
+
comp.color = color;
|
|
43
|
+
comp.visible = visible;
|
|
44
|
+
comp.flipX = flipX;
|
|
45
|
+
comp.zIndex = zIndex;
|
|
46
|
+
comp.frameIndex = resolvedFrameIndex;
|
|
47
|
+
}, [color, visible, flipX, zIndex, resolvedFrameIndex, engine, entityId]);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface SquashStretchProps {
|
|
2
|
+
/** How much to squash/stretch (default 0.2) */
|
|
3
|
+
intensity?: number;
|
|
4
|
+
/** How fast it returns to 1.0 — lerp speed (default 8) */
|
|
5
|
+
recovery?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function SquashStretch({ intensity, recovery }: SquashStretchProps): null;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { EngineContext, EntityContext } from '../context';
|
|
3
|
+
export function SquashStretch({ intensity = 0.2, recovery = 8 }) {
|
|
4
|
+
const engine = useContext(EngineContext);
|
|
5
|
+
const entityId = useContext(EntityContext);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
engine.ecs.addComponent(entityId, {
|
|
8
|
+
type: 'SquashStretch',
|
|
9
|
+
intensity,
|
|
10
|
+
recovery,
|
|
11
|
+
currentScaleX: 1,
|
|
12
|
+
currentScaleY: 1,
|
|
13
|
+
});
|
|
14
|
+
return () => engine.ecs.removeComponent(entityId, 'SquashStretch');
|
|
15
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
16
|
+
}, []);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface TiledProperty {
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
value: string | number | boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface TiledObject {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
properties?: TiledProperty[];
|
|
16
|
+
}
|
|
17
|
+
export interface TiledLayer {
|
|
18
|
+
type: 'tilelayer' | 'objectgroup';
|
|
19
|
+
name: string;
|
|
20
|
+
visible: boolean;
|
|
21
|
+
opacity: number;
|
|
22
|
+
data?: number[];
|
|
23
|
+
objects?: TiledObject[];
|
|
24
|
+
properties?: TiledProperty[];
|
|
25
|
+
}
|
|
26
|
+
interface TilemapProps {
|
|
27
|
+
/** URL to the Tiled JSON file */
|
|
28
|
+
src: string;
|
|
29
|
+
/**
|
|
30
|
+
* Object layer spawner: called for each object in object layers.
|
|
31
|
+
* Return a React element or null.
|
|
32
|
+
*/
|
|
33
|
+
onSpawnObject?: (obj: TiledObject, layer: TiledLayer) => React.ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* Layer filter: return false to skip rendering/processing a layer.
|
|
36
|
+
* Default: all layers rendered.
|
|
37
|
+
*/
|
|
38
|
+
layerFilter?: (layer: TiledLayer) => boolean;
|
|
39
|
+
/** Z-index for tile sprites (default 0) */
|
|
40
|
+
zIndex?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Name of the layer (or layers with property `collision: true`) that
|
|
43
|
+
* should create invisible solid colliders. Default: "collision".
|
|
44
|
+
*/
|
|
45
|
+
collisionLayer?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Name of the layer (or layers with property `trigger: true`) that
|
|
48
|
+
* should create trigger BoxColliders (no sprite). Default: "triggers".
|
|
49
|
+
*/
|
|
50
|
+
triggerLayer?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Called for every tile that has custom properties defined in the tileset.
|
|
53
|
+
* Receives the global tile ID, the property map, and the tile's world position.
|
|
54
|
+
*/
|
|
55
|
+
onTileProperty?: (tileId: number, properties: Record<string, unknown>, x: number, y: number) => void;
|
|
56
|
+
}
|
|
57
|
+
export declare function Tilemap({ src, onSpawnObject, layerFilter, zIndex, collisionLayer, triggerLayer: triggerLayerName, onTileProperty, }: TilemapProps): React.ReactElement | null;
|
|
58
|
+
export {};
|
|
@@ -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,10 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
interface WorldProps {
|
|
3
|
+
/** Gravitational acceleration in pixels/s² (default inherited from Game) */
|
|
4
|
+
gravity?: number;
|
|
5
|
+
/** Canvas background color */
|
|
6
|
+
background?: string;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
export declare function World({ gravity, background, children }: WorldProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -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,13 @@
|
|
|
1
|
+
export type ParticlePreset = 'explosion' | 'spark' | 'smoke' | 'coinPickup' | 'jumpDust';
|
|
2
|
+
export interface ParticleEmitterConfig {
|
|
3
|
+
rate?: number;
|
|
4
|
+
speed?: number;
|
|
5
|
+
spread?: number;
|
|
6
|
+
angle?: number;
|
|
7
|
+
particleLife?: number;
|
|
8
|
+
particleSize?: number;
|
|
9
|
+
color?: string;
|
|
10
|
+
gravity?: number;
|
|
11
|
+
maxParticles?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const PARTICLE_PRESETS: Record<ParticlePreset, ParticleEmitterConfig>;
|
|
@@ -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,8 @@
|
|
|
1
|
+
/** Maps frame names to frameIndex numbers */
|
|
2
|
+
export type SpriteAtlas = Record<string, number>;
|
|
3
|
+
/**
|
|
4
|
+
* Helper to build an atlas from a grid spritesheet.
|
|
5
|
+
* columns = number of frames per row.
|
|
6
|
+
* names = frame names in row-major order.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createAtlas(names: string[], _columns: number): SpriteAtlas;
|
|
@@ -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,19 @@
|
|
|
1
|
+
import type { ECSWorld, EventBus, AssetManager, EntityId } from '@cubeforge/core';
|
|
2
|
+
import type { InputManager } from '@cubeforge/input';
|
|
3
|
+
import type { Canvas2DRenderer } from '@cubeforge/renderer';
|
|
4
|
+
import type { PhysicsSystem } from '@cubeforge/physics';
|
|
5
|
+
import type { GameLoop } from '@cubeforge/core';
|
|
6
|
+
export interface EngineState {
|
|
7
|
+
ecs: ECSWorld;
|
|
8
|
+
input: InputManager;
|
|
9
|
+
renderer: Canvas2DRenderer;
|
|
10
|
+
physics: PhysicsSystem;
|
|
11
|
+
events: EventBus;
|
|
12
|
+
assets: AssetManager;
|
|
13
|
+
loop: GameLoop;
|
|
14
|
+
canvas: HTMLCanvasElement;
|
|
15
|
+
/** Maps string entity IDs (e.g. "player") to numeric ECS EntityIds */
|
|
16
|
+
entityIds: Map<string, EntityId>;
|
|
17
|
+
}
|
|
18
|
+
export declare const EngineContext: import("react").Context<EngineState | null>;
|
|
19
|
+
export declare const EntityContext: import("react").Context<number | null>;
|
package/dist/context.js
ADDED
|
@@ -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
|
+
}
|