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,12 @@
|
|
|
1
|
+
interface AnimationProps {
|
|
2
|
+
/** Frame indices to play (indexes into the sprite sheet) */
|
|
3
|
+
frames: number[];
|
|
4
|
+
/** Frames per second, default 12 */
|
|
5
|
+
fps?: number;
|
|
6
|
+
/** Whether to loop, default true */
|
|
7
|
+
loop?: boolean;
|
|
8
|
+
/** Whether currently playing, default true */
|
|
9
|
+
playing?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function Animation({ frames, fps, loop, playing }: AnimationProps): null;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { EngineContext, EntityContext } from '../context';
|
|
3
|
+
export function Animation({ frames, fps = 12, loop = true, playing = true }) {
|
|
4
|
+
const engine = useContext(EngineContext);
|
|
5
|
+
const entityId = useContext(EntityContext);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const state = {
|
|
8
|
+
type: 'AnimationState',
|
|
9
|
+
frames,
|
|
10
|
+
fps,
|
|
11
|
+
loop,
|
|
12
|
+
playing,
|
|
13
|
+
currentIndex: 0,
|
|
14
|
+
timer: 0,
|
|
15
|
+
};
|
|
16
|
+
engine.ecs.addComponent(entityId, state);
|
|
17
|
+
return () => {
|
|
18
|
+
engine.ecs.removeComponent(entityId, 'AnimationState');
|
|
19
|
+
};
|
|
20
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
21
|
+
}, []);
|
|
22
|
+
// Sync playing state and animation params
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const anim = engine.ecs.getComponent(entityId, 'AnimationState');
|
|
25
|
+
if (!anim)
|
|
26
|
+
return;
|
|
27
|
+
anim.playing = playing;
|
|
28
|
+
anim.fps = fps;
|
|
29
|
+
anim.loop = loop;
|
|
30
|
+
}, [playing, fps, loop, engine, entityId]);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface BoxColliderProps {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
offsetX?: number;
|
|
5
|
+
offsetY?: number;
|
|
6
|
+
isTrigger?: boolean;
|
|
7
|
+
layer?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function BoxCollider({ width, height, offsetX, offsetY, isTrigger, layer, }: BoxColliderProps): null;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { createBoxCollider } from '@cubeforge/physics';
|
|
3
|
+
import { EngineContext, EntityContext } from '../context';
|
|
4
|
+
export function BoxCollider({ width, height, offsetX = 0, offsetY = 0, isTrigger = false, layer = 'default', }) {
|
|
5
|
+
const engine = useContext(EngineContext);
|
|
6
|
+
const entityId = useContext(EntityContext);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
engine.ecs.addComponent(entityId, createBoxCollider(width, height, { offsetX, offsetY, isTrigger, layer }));
|
|
9
|
+
// Defer check so sibling components have had a chance to add their components
|
|
10
|
+
const checkId = setTimeout(() => {
|
|
11
|
+
if (engine.ecs.hasEntity(entityId) && !engine.ecs.hasComponent(entityId, 'Transform')) {
|
|
12
|
+
console.warn(`[Cubeforge] BoxCollider on entity ${entityId} has no Transform. Physics requires Transform.`);
|
|
13
|
+
}
|
|
14
|
+
}, 0);
|
|
15
|
+
return () => {
|
|
16
|
+
clearTimeout(checkId);
|
|
17
|
+
engine.ecs.removeComponent(entityId, 'BoxCollider');
|
|
18
|
+
};
|
|
19
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface Camera2DProps {
|
|
2
|
+
/** String ID of entity to follow */
|
|
3
|
+
followEntity?: string;
|
|
4
|
+
zoom?: number;
|
|
5
|
+
/** Lerp smoothing factor (0 = instant snap, 0.85 = smooth) */
|
|
6
|
+
smoothing?: number;
|
|
7
|
+
background?: string;
|
|
8
|
+
bounds?: {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
deadZone?: {
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare function Camera2D({ followEntity, zoom, smoothing, background, bounds, deadZone, }: Camera2DProps): null;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { createCamera2D } from '@cubeforge/renderer';
|
|
3
|
+
import { EngineContext } from '../context';
|
|
4
|
+
export function Camera2D({ followEntity, zoom = 1, smoothing = 0, background = '#1a1a2e', bounds, deadZone, }) {
|
|
5
|
+
const engine = useContext(EngineContext);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const entityId = engine.ecs.createEntity();
|
|
8
|
+
engine.ecs.addComponent(entityId, createCamera2D({
|
|
9
|
+
followEntityId: followEntity,
|
|
10
|
+
zoom,
|
|
11
|
+
smoothing,
|
|
12
|
+
background,
|
|
13
|
+
bounds,
|
|
14
|
+
deadZone,
|
|
15
|
+
}));
|
|
16
|
+
return () => engine.ecs.destroyEntity(entityId);
|
|
17
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
18
|
+
}, []);
|
|
19
|
+
// Sync prop changes
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const camId = engine.ecs.queryOne('Camera2D');
|
|
22
|
+
if (camId === undefined)
|
|
23
|
+
return;
|
|
24
|
+
const cam = engine.ecs.getComponent(camId, 'Camera2D');
|
|
25
|
+
cam.followEntityId = followEntity;
|
|
26
|
+
cam.zoom = zoom;
|
|
27
|
+
cam.smoothing = smoothing;
|
|
28
|
+
cam.background = background;
|
|
29
|
+
cam.bounds = bounds;
|
|
30
|
+
cam.deadZone = deadZone;
|
|
31
|
+
}, [followEntity, zoom, smoothing, background, bounds, deadZone, engine]);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface CheckpointProps {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
color?: string;
|
|
8
|
+
/** Called once when a 'player'-tagged entity enters the checkpoint */
|
|
9
|
+
onActivate?: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A trigger zone that fires `onActivate` when the player enters it, then destroys itself.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <Checkpoint x={800} y={450} onActivate={() => setCheckpoint(800)} />
|
|
16
|
+
*/
|
|
17
|
+
export declare function Checkpoint({ x, y, width, height, color, onActivate, }: CheckpointProps): React.ReactElement;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Entity } from './Entity';
|
|
3
|
+
import { Transform } from './Transform';
|
|
4
|
+
import { Sprite } from './Sprite';
|
|
5
|
+
import { BoxCollider } from './BoxCollider';
|
|
6
|
+
import { Script } from './Script';
|
|
7
|
+
/**
|
|
8
|
+
* A trigger zone that fires `onActivate` when the player enters it, then destroys itself.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <Checkpoint x={800} y={450} onActivate={() => setCheckpoint(800)} />
|
|
12
|
+
*/
|
|
13
|
+
export function Checkpoint({ x, y, width = 24, height = 48, color = '#ffd54f', onActivate, }) {
|
|
14
|
+
return (_jsxs(Entity, { tags: ['checkpoint'], children: [_jsx(Transform, { x: x, y: y }), _jsx(Sprite, { width: width, height: height, color: color, zIndex: 5 }), _jsx(BoxCollider, { width: width, height: height, isTrigger: true }), _jsx(Script, { init: () => { }, update: (id, world) => {
|
|
15
|
+
if (!world.hasEntity(id))
|
|
16
|
+
return;
|
|
17
|
+
const ct = world.getComponent(id, 'Transform');
|
|
18
|
+
if (!ct)
|
|
19
|
+
return;
|
|
20
|
+
for (const pid of world.query('Tag')) {
|
|
21
|
+
const tag = world.getComponent(pid, 'Tag');
|
|
22
|
+
if (!tag?.tags.includes('player'))
|
|
23
|
+
continue;
|
|
24
|
+
const pt = world.getComponent(pid, 'Transform');
|
|
25
|
+
if (!pt)
|
|
26
|
+
continue;
|
|
27
|
+
const dx = Math.abs(pt.x - ct.x);
|
|
28
|
+
const dy = Math.abs(pt.y - ct.y);
|
|
29
|
+
if (dx < (width / 2 + 16) && dy < (height / 2 + 20)) {
|
|
30
|
+
onActivate?.();
|
|
31
|
+
world.destroyEntity(id);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} })] }));
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
interface EntityProps {
|
|
3
|
+
/** Optional string ID for cross-entity lookups (e.g. camera follow) */
|
|
4
|
+
id?: string;
|
|
5
|
+
/** Tags for grouping / querying (e.g. ['enemy', 'damageable']) */
|
|
6
|
+
tags?: string[];
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
export declare function Entity({ id, tags, children }: EntityProps): import("react/jsx-runtime").JSX.Element | null;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useContext, useState } from 'react';
|
|
3
|
+
import { createTag } from '@cubeforge/core';
|
|
4
|
+
import { EngineContext, EntityContext } from '../context';
|
|
5
|
+
export function Entity({ id, tags = [], children }) {
|
|
6
|
+
const engine = useContext(EngineContext);
|
|
7
|
+
const [entityId, setEntityId] = useState(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const eid = engine.ecs.createEntity();
|
|
10
|
+
if (id) {
|
|
11
|
+
if (engine.entityIds.has(id)) {
|
|
12
|
+
console.warn(`[Cubeforge] Duplicate entity ID "${id}". Entity IDs must be unique — the previous entity with this ID will be replaced.`);
|
|
13
|
+
}
|
|
14
|
+
engine.entityIds.set(id, eid);
|
|
15
|
+
}
|
|
16
|
+
if (tags.length > 0)
|
|
17
|
+
engine.ecs.addComponent(eid, createTag(...tags));
|
|
18
|
+
setEntityId(eid);
|
|
19
|
+
return () => {
|
|
20
|
+
engine.ecs.destroyEntity(eid);
|
|
21
|
+
if (id)
|
|
22
|
+
engine.entityIds.delete(id);
|
|
23
|
+
};
|
|
24
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
|
+
}, []);
|
|
26
|
+
if (entityId === null)
|
|
27
|
+
return null;
|
|
28
|
+
return (_jsx(EntityContext.Provider, { value: entityId, children: children }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { type CSSProperties } from 'react';
|
|
2
|
+
export interface GameControls {
|
|
3
|
+
pause(): void;
|
|
4
|
+
resume(): void;
|
|
5
|
+
reset(): void;
|
|
6
|
+
}
|
|
7
|
+
interface GameProps {
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
/** Pixels per second squared downward (default 980) */
|
|
11
|
+
gravity?: number;
|
|
12
|
+
/** Enable debug overlay: collider wireframes, FPS, entity count */
|
|
13
|
+
debug?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Canvas scaling strategy (default 'none'):
|
|
16
|
+
* - 'none' — fixed pixel size, no scaling
|
|
17
|
+
* - 'contain' — CSS scale to fit parent while preserving aspect ratio
|
|
18
|
+
* - 'pixel' — nearest-neighbor pixel-art scaling via CSS
|
|
19
|
+
*/
|
|
20
|
+
scale?: 'none' | 'contain' | 'pixel';
|
|
21
|
+
/** Called once the engine is ready — receives pause/resume/reset controls */
|
|
22
|
+
onReady?: (controls: GameControls) => void;
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
className?: string;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
export declare function Game({ width, height, gravity, debug, scale, onReady, style, className, children, }: GameProps): import("react/jsx-runtime").JSX.Element;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { ECSWorld, GameLoop, EventBus, AssetManager, ScriptSystem } from '@cubeforge/core';
|
|
4
|
+
import { InputManager } from '@cubeforge/input';
|
|
5
|
+
import { Canvas2DRenderer, RenderSystem } from '@cubeforge/renderer';
|
|
6
|
+
import { PhysicsSystem } from '@cubeforge/physics';
|
|
7
|
+
import { EngineContext } from '../context';
|
|
8
|
+
import { DebugSystem } from '../systems/debugSystem';
|
|
9
|
+
export function Game({ width = 800, height = 600, gravity = 980, debug = false, scale = 'none', onReady, style, className, children, }) {
|
|
10
|
+
const canvasRef = useRef(null);
|
|
11
|
+
const wrapperRef = useRef(null);
|
|
12
|
+
const [engine, setEngine] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const canvas = canvasRef.current;
|
|
15
|
+
const ecs = new ECSWorld();
|
|
16
|
+
const input = new InputManager();
|
|
17
|
+
const renderer = new Canvas2DRenderer(canvas);
|
|
18
|
+
const events = new EventBus();
|
|
19
|
+
const assets = new AssetManager();
|
|
20
|
+
const physics = new PhysicsSystem(gravity, events);
|
|
21
|
+
const entityIds = new Map();
|
|
22
|
+
const renderSystem = new RenderSystem(renderer, entityIds);
|
|
23
|
+
const debugSystem = debug ? new DebugSystem(renderer) : null;
|
|
24
|
+
// System order: scripts → physics → render → (debug)
|
|
25
|
+
ecs.addSystem(new ScriptSystem(input));
|
|
26
|
+
ecs.addSystem(physics);
|
|
27
|
+
ecs.addSystem(renderSystem);
|
|
28
|
+
if (debugSystem)
|
|
29
|
+
ecs.addSystem(debugSystem);
|
|
30
|
+
input.attach(canvas);
|
|
31
|
+
canvas.setAttribute('tabindex', '0');
|
|
32
|
+
// Validate dimensions
|
|
33
|
+
if (width <= 0 || height <= 0) {
|
|
34
|
+
console.warn(`[Cubeforge] Invalid Game dimensions: ${width}x${height}. Width and height must be positive.`);
|
|
35
|
+
}
|
|
36
|
+
const loop = new GameLoop((dt) => {
|
|
37
|
+
ecs.update(dt);
|
|
38
|
+
input.flush();
|
|
39
|
+
});
|
|
40
|
+
const state = { ecs, input, renderer, physics, events, assets, loop, canvas, entityIds };
|
|
41
|
+
setEngine(state);
|
|
42
|
+
loop.start();
|
|
43
|
+
// Expose controls via onReady callback
|
|
44
|
+
onReady?.({
|
|
45
|
+
pause: () => loop.pause(),
|
|
46
|
+
resume: () => loop.resume(),
|
|
47
|
+
reset: () => {
|
|
48
|
+
ecs.clear();
|
|
49
|
+
loop.stop();
|
|
50
|
+
loop.start();
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
// Handle contain scaling
|
|
54
|
+
let resizeObserver = null;
|
|
55
|
+
if (scale === 'contain' && wrapperRef.current) {
|
|
56
|
+
const wrapper = wrapperRef.current;
|
|
57
|
+
const updateScale = () => {
|
|
58
|
+
const parentW = wrapper.parentElement?.clientWidth ?? width;
|
|
59
|
+
const parentH = wrapper.parentElement?.clientHeight ?? height;
|
|
60
|
+
const scaleX = parentW / width;
|
|
61
|
+
const scaleY = parentH / height;
|
|
62
|
+
const s = Math.min(scaleX, scaleY);
|
|
63
|
+
canvas.style.transform = `scale(${s})`;
|
|
64
|
+
canvas.style.transformOrigin = 'top left';
|
|
65
|
+
};
|
|
66
|
+
updateScale();
|
|
67
|
+
resizeObserver = new ResizeObserver(updateScale);
|
|
68
|
+
if (wrapper.parentElement)
|
|
69
|
+
resizeObserver.observe(wrapper.parentElement);
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
loop.stop();
|
|
73
|
+
input.detach();
|
|
74
|
+
ecs.clear();
|
|
75
|
+
resizeObserver?.disconnect();
|
|
76
|
+
};
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, []);
|
|
79
|
+
// Sync gravity changes
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
engine?.physics.setGravity(gravity);
|
|
82
|
+
}, [gravity, engine]);
|
|
83
|
+
const canvasStyle = {
|
|
84
|
+
display: 'block',
|
|
85
|
+
outline: 'none',
|
|
86
|
+
imageRendering: scale === 'pixel' ? 'pixelated' : undefined,
|
|
87
|
+
...style,
|
|
88
|
+
};
|
|
89
|
+
const wrapperStyle = scale === 'contain'
|
|
90
|
+
? { position: 'relative', width, height, overflow: 'visible' }
|
|
91
|
+
: {};
|
|
92
|
+
return (_jsxs(EngineContext.Provider, { value: engine, children: [_jsx("div", { ref: wrapperRef, style: wrapperStyle, children: _jsx("canvas", { ref: canvasRef, width: width, height: height, style: canvasStyle, className: className }) }), engine && children] }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface MovingPlatformProps {
|
|
3
|
+
/** Start position */
|
|
4
|
+
x1: number;
|
|
5
|
+
y1: number;
|
|
6
|
+
/** End position */
|
|
7
|
+
x2: number;
|
|
8
|
+
y2: number;
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
/** Seconds for a full round trip (default 3) */
|
|
12
|
+
duration?: number;
|
|
13
|
+
color?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A static platform that oscillates between (x1,y1) and (x2,y2).
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <MovingPlatform x1={200} y1={350} x2={450} y2={350} width={120} duration={2.5} />
|
|
20
|
+
*/
|
|
21
|
+
export declare function MovingPlatform({ x1, y1, x2, y2, width, height, duration, color, }: MovingPlatformProps): React.ReactElement;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Entity } from './Entity';
|
|
3
|
+
import { Transform } from './Transform';
|
|
4
|
+
import { Sprite } from './Sprite';
|
|
5
|
+
import { RigidBody } from './RigidBody';
|
|
6
|
+
import { BoxCollider } from './BoxCollider';
|
|
7
|
+
import { Script } from './Script';
|
|
8
|
+
const platformPhases = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* A static platform that oscillates between (x1,y1) and (x2,y2).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <MovingPlatform x1={200} y1={350} x2={450} y2={350} width={120} duration={2.5} />
|
|
14
|
+
*/
|
|
15
|
+
export function MovingPlatform({ x1, y1, x2, y2, width = 120, height = 18, duration = 3, color = '#37474f', }) {
|
|
16
|
+
return (_jsxs(Entity, { children: [_jsx(Transform, { x: x1, y: y1 }), _jsx(Sprite, { width: width, height: height, color: color, zIndex: 5 }), _jsx(RigidBody, { isStatic: true }), _jsx(BoxCollider, { width: width, height: height }), _jsx(Script, { init: () => { }, update: (id, world, _input, dt) => {
|
|
17
|
+
if (!world.hasEntity(id))
|
|
18
|
+
return;
|
|
19
|
+
const t = world.getComponent(id, 'Transform');
|
|
20
|
+
if (!t)
|
|
21
|
+
return;
|
|
22
|
+
const phase = (platformPhases.get(id) ?? 0) + dt * (Math.PI * 2) / duration;
|
|
23
|
+
platformPhases.set(id, phase);
|
|
24
|
+
const alpha = (Math.sin(phase) + 1) / 2; // 0..1
|
|
25
|
+
t.x = x1 + (x2 - x1) * alpha;
|
|
26
|
+
t.y = y1 + (y2 - y1) * alpha;
|
|
27
|
+
} })] }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface ParallaxLayerProps {
|
|
3
|
+
/** Image URL to use as the background layer */
|
|
4
|
+
src: string;
|
|
5
|
+
/** Scroll speed relative to camera (0 = fixed, 1 = moves with camera, 0.3 = slow parallax). Default 0.5 */
|
|
6
|
+
speedX?: number;
|
|
7
|
+
/** Vertical scroll speed relative to camera. Default 0 */
|
|
8
|
+
speedY?: number;
|
|
9
|
+
/** Tile image horizontally. Default true */
|
|
10
|
+
repeatX?: boolean;
|
|
11
|
+
/** Tile image vertically. Default false */
|
|
12
|
+
repeatY?: boolean;
|
|
13
|
+
/** Render order — use negative values to render behind sprites. Default -10 */
|
|
14
|
+
zIndex?: number;
|
|
15
|
+
/** Manual horizontal offset in pixels. Default 0 */
|
|
16
|
+
offsetX?: number;
|
|
17
|
+
/** Manual vertical offset in pixels. Default 0 */
|
|
18
|
+
offsetY?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A background layer that scrolls at a fraction of the camera speed to create depth.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <ParallaxLayer src="/bg/sky.png" speedX={0.2} repeatX />
|
|
25
|
+
* <ParallaxLayer src="/bg/mountains.png" speedX={0.5} repeatX zIndex={-5} />
|
|
26
|
+
*/
|
|
27
|
+
export declare function ParallaxLayer({ src, speedX, speedY, repeatX, repeatY, zIndex, offsetX, offsetY, }: ParallaxLayerProps): React.ReactElement;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useContext } from 'react';
|
|
3
|
+
import { Entity } from './Entity';
|
|
4
|
+
import { Transform } from './Transform';
|
|
5
|
+
import { EngineContext, EntityContext } from '../context';
|
|
6
|
+
function ParallaxLayerInner({ src, speedX, speedY, repeatX, repeatY, zIndex, offsetX, offsetY, }) {
|
|
7
|
+
const engine = useContext(EngineContext);
|
|
8
|
+
const entityId = useContext(EntityContext);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
engine.ecs.addComponent(entityId, {
|
|
11
|
+
type: 'ParallaxLayer',
|
|
12
|
+
src,
|
|
13
|
+
speedX,
|
|
14
|
+
speedY,
|
|
15
|
+
repeatX,
|
|
16
|
+
repeatY,
|
|
17
|
+
zIndex,
|
|
18
|
+
offsetX,
|
|
19
|
+
offsetY,
|
|
20
|
+
imageWidth: 0,
|
|
21
|
+
imageHeight: 0,
|
|
22
|
+
});
|
|
23
|
+
return () => engine.ecs.removeComponent(entityId, 'ParallaxLayer');
|
|
24
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
|
+
}, []);
|
|
26
|
+
// Sync prop changes
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const layer = engine.ecs.getComponent(entityId, 'ParallaxLayer');
|
|
29
|
+
if (!layer)
|
|
30
|
+
return;
|
|
31
|
+
layer.src = src;
|
|
32
|
+
layer.speedX = speedX;
|
|
33
|
+
layer.speedY = speedY;
|
|
34
|
+
layer.repeatX = repeatX;
|
|
35
|
+
layer.repeatY = repeatY;
|
|
36
|
+
layer.zIndex = zIndex;
|
|
37
|
+
layer.offsetX = offsetX;
|
|
38
|
+
layer.offsetY = offsetY;
|
|
39
|
+
}, [src, speedX, speedY, repeatX, repeatY, zIndex, offsetX, offsetY, engine, entityId]);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A background layer that scrolls at a fraction of the camera speed to create depth.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* <ParallaxLayer src="/bg/sky.png" speedX={0.2} repeatX />
|
|
47
|
+
* <ParallaxLayer src="/bg/mountains.png" speedX={0.5} repeatX zIndex={-5} />
|
|
48
|
+
*/
|
|
49
|
+
export function ParallaxLayer({ src, speedX = 0.5, speedY = 0, repeatX = true, repeatY = false, zIndex = -10, offsetX = 0, offsetY = 0, }) {
|
|
50
|
+
return (_jsxs(Entity, { children: [_jsx(Transform, { x: 0, y: 0 }), _jsx(ParallaxLayerInner, { src: src, speedX: speedX, speedY: speedY, repeatX: repeatX, repeatY: repeatY, zIndex: zIndex, offsetX: offsetX, offsetY: offsetY })] }));
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ParticlePreset } from './particlePresets';
|
|
2
|
+
interface ParticleEmitterProps {
|
|
3
|
+
active?: boolean;
|
|
4
|
+
/** Named preset — values can be overridden by explicit props */
|
|
5
|
+
preset?: ParticlePreset;
|
|
6
|
+
/** Particles per second, default 20 */
|
|
7
|
+
rate?: number;
|
|
8
|
+
/** Initial particle speed (pixels/s), default 80 */
|
|
9
|
+
speed?: number;
|
|
10
|
+
/** Angle spread in radians, default Math.PI */
|
|
11
|
+
spread?: number;
|
|
12
|
+
/** Base emit angle in radians (0=right, -PI/2=up), default -Math.PI/2 */
|
|
13
|
+
angle?: number;
|
|
14
|
+
/** Particle lifetime in seconds, default 0.8 */
|
|
15
|
+
particleLife?: number;
|
|
16
|
+
/** Particle size in pixels, default 4 */
|
|
17
|
+
particleSize?: number;
|
|
18
|
+
/** Particle color, default '#ffffff' */
|
|
19
|
+
color?: string;
|
|
20
|
+
/** Gravity applied to particles (pixels/s²), default 200 */
|
|
21
|
+
gravity?: number;
|
|
22
|
+
/** Maximum live particles, default 100 */
|
|
23
|
+
maxParticles?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function ParticleEmitter({ active, preset, rate, speed, spread, angle, particleLife, particleSize, color, gravity, maxParticles, }: ParticleEmitterProps): null;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { EngineContext, EntityContext } from '../context';
|
|
3
|
+
import { PARTICLE_PRESETS } from './particlePresets';
|
|
4
|
+
export function ParticleEmitter({ active = true, preset, rate, speed, spread, angle, particleLife, particleSize, color, gravity, maxParticles, }) {
|
|
5
|
+
const presetConfig = preset ? PARTICLE_PRESETS[preset] : {};
|
|
6
|
+
const resolvedRate = rate ?? presetConfig.rate ?? 20;
|
|
7
|
+
const resolvedSpeed = speed ?? presetConfig.speed ?? 80;
|
|
8
|
+
const resolvedSpread = spread ?? presetConfig.spread ?? Math.PI;
|
|
9
|
+
const resolvedAngle = angle ?? presetConfig.angle ?? -Math.PI / 2;
|
|
10
|
+
const resolvedParticleLife = particleLife ?? presetConfig.particleLife ?? 0.8;
|
|
11
|
+
const resolvedParticleSize = particleSize ?? presetConfig.particleSize ?? 4;
|
|
12
|
+
const resolvedColor = color ?? presetConfig.color ?? '#ffffff';
|
|
13
|
+
const resolvedGravity = gravity ?? presetConfig.gravity ?? 200;
|
|
14
|
+
const resolvedMaxParticles = maxParticles ?? presetConfig.maxParticles ?? 100;
|
|
15
|
+
const engine = useContext(EngineContext);
|
|
16
|
+
const entityId = useContext(EntityContext);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
engine.ecs.addComponent(entityId, {
|
|
19
|
+
type: 'ParticlePool',
|
|
20
|
+
particles: [],
|
|
21
|
+
maxParticles: resolvedMaxParticles,
|
|
22
|
+
active,
|
|
23
|
+
rate: resolvedRate,
|
|
24
|
+
timer: 0,
|
|
25
|
+
speed: resolvedSpeed,
|
|
26
|
+
spread: resolvedSpread,
|
|
27
|
+
angle: resolvedAngle,
|
|
28
|
+
particleLife: resolvedParticleLife,
|
|
29
|
+
particleSize: resolvedParticleSize,
|
|
30
|
+
color: resolvedColor,
|
|
31
|
+
gravity: resolvedGravity,
|
|
32
|
+
});
|
|
33
|
+
return () => engine.ecs.removeComponent(entityId, 'ParticlePool');
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, []);
|
|
36
|
+
// Sync active state
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const pool = engine.ecs.getComponent(entityId, 'ParticlePool');
|
|
39
|
+
if (!pool)
|
|
40
|
+
return;
|
|
41
|
+
pool.active = active;
|
|
42
|
+
}, [active, engine, entityId]);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface RigidBodyProps {
|
|
2
|
+
mass?: number;
|
|
3
|
+
gravityScale?: number;
|
|
4
|
+
isStatic?: boolean;
|
|
5
|
+
bounce?: number;
|
|
6
|
+
friction?: number;
|
|
7
|
+
vx?: number;
|
|
8
|
+
vy?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function RigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy, }: RigidBodyProps): null;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { createRigidBody } from '@cubeforge/physics';
|
|
3
|
+
import { EngineContext, EntityContext } from '../context';
|
|
4
|
+
export function RigidBody({ mass = 1, gravityScale = 1, isStatic = false, bounce = 0, friction = 0.85, vx = 0, vy = 0, }) {
|
|
5
|
+
const engine = useContext(EngineContext);
|
|
6
|
+
const entityId = useContext(EntityContext);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
engine.ecs.addComponent(entityId, createRigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy }));
|
|
9
|
+
return () => engine.ecs.removeComponent(entityId, 'RigidBody');
|
|
10
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
11
|
+
}, []);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
|
3
|
+
export const ScreenFlash = forwardRef((_, ref) => {
|
|
4
|
+
const divRef = useRef(null);
|
|
5
|
+
useImperativeHandle(ref, () => ({
|
|
6
|
+
flash(color, duration) {
|
|
7
|
+
const el = divRef.current;
|
|
8
|
+
if (!el)
|
|
9
|
+
return;
|
|
10
|
+
el.style.backgroundColor = color;
|
|
11
|
+
el.style.opacity = '1';
|
|
12
|
+
el.style.transition = 'none';
|
|
13
|
+
const durationMs = duration * 1000;
|
|
14
|
+
// Next frame: start the fade
|
|
15
|
+
requestAnimationFrame(() => {
|
|
16
|
+
requestAnimationFrame(() => {
|
|
17
|
+
if (!divRef.current)
|
|
18
|
+
return;
|
|
19
|
+
divRef.current.style.transition = `opacity ${durationMs}ms linear`;
|
|
20
|
+
divRef.current.style.opacity = '0';
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
return (_jsx("div", { ref: divRef, style: {
|
|
26
|
+
position: 'absolute',
|
|
27
|
+
inset: 0,
|
|
28
|
+
pointerEvents: 'none',
|
|
29
|
+
zIndex: 9999,
|
|
30
|
+
opacity: 0,
|
|
31
|
+
backgroundColor: 'transparent',
|
|
32
|
+
} }));
|
|
33
|
+
});
|
|
34
|
+
ScreenFlash.displayName = 'ScreenFlash';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ScriptUpdateFn } from '@cubeforge/core';
|
|
2
|
+
import type { ECSWorld, EntityId } from '@cubeforge/core';
|
|
3
|
+
import type { InputManager } from '@cubeforge/input';
|
|
4
|
+
interface ScriptProps {
|
|
5
|
+
/** Called once when the entity is mounted — use to attach extra components */
|
|
6
|
+
init?: (entityId: EntityId, world: ECSWorld) => void;
|
|
7
|
+
/** Called every frame */
|
|
8
|
+
update: ScriptUpdateFn | ((entityId: EntityId, world: ECSWorld, input: InputManager, dt: number) => void);
|
|
9
|
+
}
|
|
10
|
+
export declare function Script({ init, update }: ScriptProps): null;
|
|
11
|
+
export {};
|