cubeforge 0.1.6 → 0.1.7

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/index.d.mts CHANGED
@@ -6,7 +6,7 @@ export { Component, ECSWorld, Ease, EntityId, Plugin, ScriptUpdateFn, TransformC
6
6
  import { InputManager, ActionBindings } from '@cubeforge/input';
7
7
  export { ActionBindings, InputManager, InputMap, createInputMap } from '@cubeforge/input';
8
8
  import { Canvas2DRenderer, RenderSystem } from '@cubeforge/renderer';
9
- export { AnimationStateComponent, ParallaxLayerComponent, Particle, ParticlePoolComponent, SpriteComponent, SquashStretchComponent, createSprite } from '@cubeforge/renderer';
9
+ export { AnimationStateComponent, ParallaxLayerComponent, Particle, ParticlePoolComponent, SpriteComponent, SquashStretchComponent, TextComponent, createSprite } from '@cubeforge/renderer';
10
10
  import { PhysicsSystem } from '@cubeforge/physics';
11
11
  export { BoxColliderComponent, CircleColliderComponent, RaycastHit, RigidBodyComponent, overlapBox, raycast } from '@cubeforge/physics';
12
12
 
@@ -114,8 +114,25 @@ interface SpriteProps {
114
114
  frameColumns?: number;
115
115
  atlas?: SpriteAtlas;
116
116
  frame?: string;
117
+ tileX?: boolean;
118
+ tileY?: boolean;
117
119
  }
118
- declare function Sprite({ width, height, color, src, offsetX, offsetY, zIndex, visible, flipX, anchorX, anchorY, frameIndex, frameWidth, frameHeight, frameColumns, atlas, frame, }: SpriteProps): null;
120
+ declare function Sprite({ width, height, color, src, offsetX, offsetY, zIndex, visible, flipX, anchorX, anchorY, frameIndex, frameWidth, frameHeight, frameColumns, atlas, frame, tileX, tileY, }: SpriteProps): null;
121
+
122
+ interface TextProps {
123
+ text: string;
124
+ fontSize?: number;
125
+ fontFamily?: string;
126
+ color?: string;
127
+ align?: CanvasTextAlign;
128
+ baseline?: CanvasTextBaseline;
129
+ zIndex?: number;
130
+ visible?: boolean;
131
+ maxWidth?: number;
132
+ offsetX?: number;
133
+ offsetY?: number;
134
+ }
135
+ declare function Text({ text, fontSize, fontFamily, color, align, baseline, zIndex, visible, maxWidth, offsetX, offsetY, }: TextProps): null;
119
136
 
120
137
  interface RigidBodyProps {
121
138
  mass?: number;
@@ -512,9 +529,28 @@ interface PlatformerControllerOptions {
512
529
  coyoteTime?: number;
513
530
  /** Seconds to buffer a jump input before landing (default 0.08) */
514
531
  jumpBuffer?: number;
532
+ /**
533
+ * Minimum seconds between jumps — prevents multi-jump spam when holding the
534
+ * jump key with double-jump enabled (default 0.18)
535
+ */
536
+ jumpCooldown?: number;
537
+ /**
538
+ * Override the default key bindings. Each action accepts a single key code
539
+ * or an array of key codes.
540
+ *
541
+ * Defaults:
542
+ * left: ['ArrowLeft', 'KeyA', 'a']
543
+ * right: ['ArrowRight', 'KeyD', 'd']
544
+ * jump: ['Space', 'ArrowUp', 'KeyW', 'w']
545
+ */
546
+ bindings?: {
547
+ left?: string | string[];
548
+ right?: string | string[];
549
+ jump?: string | string[];
550
+ };
515
551
  }
516
552
  /**
517
- * Attaches platformer movement (WASD/Arrows + Space/Up to jump) to an entity.
553
+ * Attaches platformer movement (customisable keys + Space/Up to jump) to an entity.
518
554
  * The entity must already have a RigidBody component.
519
555
  *
520
556
  * @example
@@ -555,6 +591,116 @@ interface TopDownMovementOptions {
555
591
  */
556
592
  declare function useTopDownMovement(entityId: EntityId, opts?: TopDownMovementOptions): void;
557
593
 
594
+ interface SoundControls {
595
+ /** Start playing. If already playing and loop=false, restarts from the beginning. */
596
+ play(): void;
597
+ /** Stop the current playback. */
598
+ stop(): void;
599
+ /** Change the volume (0–1). */
600
+ setVolume(v: number): void;
601
+ }
602
+ /**
603
+ * Loads and plays an audio file via the Web Audio API.
604
+ *
605
+ * The AudioContext is created lazily — the first call to `play()` resumes it
606
+ * if the browser suspended it before a user gesture.
607
+ *
608
+ * @example
609
+ * function JumpSfx() {
610
+ * const { play } = useSound('/jump.wav')
611
+ * // call play() on jump event
612
+ * }
613
+ */
614
+ declare function useSound(src: string, opts?: {
615
+ volume?: number;
616
+ loop?: boolean;
617
+ }): SoundControls;
618
+
619
+ /**
620
+ * A lightweight game-loop timer for use inside Script update functions.
621
+ *
622
+ * @example
623
+ * // Create once outside the update function (e.g. in playerInit):
624
+ * const invincibleTimer = createTimer(2.0, () => { state.isInvincible = false })
625
+ *
626
+ * // Call update(dt) inside the Script update function each frame:
627
+ * function playerUpdate(id, world, input, dt) {
628
+ * invincibleTimer.update(dt)
629
+ * if (someHitCondition) {
630
+ * state.isInvincible = true
631
+ * invincibleTimer.restart()
632
+ * }
633
+ * }
634
+ */
635
+ interface GameTimer {
636
+ /** Advance the timer by dt seconds. Calls onComplete when it reaches zero. */
637
+ update(dt: number): void;
638
+ /** Start (or resume) counting down. */
639
+ start(): void;
640
+ /** Pause counting without resetting. */
641
+ stop(): void;
642
+ /** Reset elapsed time to 0 and stop. Optionally change the duration. */
643
+ reset(newDuration?: number): void;
644
+ /** Reset elapsed time to 0 and immediately start. */
645
+ restart(): void;
646
+ /** Whether the timer is currently counting. */
647
+ readonly running: boolean;
648
+ /** Elapsed seconds since last reset/restart. */
649
+ readonly elapsed: number;
650
+ /** Remaining seconds (clamped to 0). */
651
+ readonly remaining: number;
652
+ /** Progress from 0 (just started) to 1 (complete). */
653
+ readonly progress: number;
654
+ }
655
+ declare function createTimer(duration: number, onComplete?: () => void, autoStart?: boolean): GameTimer;
656
+
657
+ interface GamepadState {
658
+ /** Whether a gamepad is connected at this player index. */
659
+ connected: boolean;
660
+ /**
661
+ * Normalised axis values (typically −1 to +1).
662
+ * Index 0 = left stick X, 1 = left stick Y, 2 = right stick X, 3 = right stick Y
663
+ * (varies by browser / controller).
664
+ */
665
+ axes: readonly number[];
666
+ /**
667
+ * Button pressed states.
668
+ * Standard mapping: 0=A/Cross, 1=B/Circle, 2=X/Square, 3=Y/Triangle,
669
+ * 4=LB, 5=RB, 12=DPad-Up, 13=DPad-Down, 14=DPad-Left, 15=DPad-Right
670
+ */
671
+ buttons: readonly boolean[];
672
+ }
673
+ /**
674
+ * Polls the Gamepad API every frame and returns the current state.
675
+ *
676
+ * @param playerIndex - Which gamepad slot to read (0–3). Default 0.
677
+ *
678
+ * @example
679
+ * function MoveWithGamepad() {
680
+ * const gp = useGamepad()
681
+ * // gp.axes[0] = left stick horizontal
682
+ * // gp.buttons[0] = A button
683
+ * }
684
+ */
685
+ declare function useGamepad(playerIndex?: number): GamepadState;
686
+
687
+ interface PauseControls {
688
+ paused: boolean;
689
+ pause(): void;
690
+ resume(): void;
691
+ toggle(): void;
692
+ }
693
+ /**
694
+ * Controls the game loop pause state from inside a `<Game>` tree.
695
+ *
696
+ * @example
697
+ * function PauseButton() {
698
+ * const { paused, toggle } = usePause()
699
+ * return <button onClick={toggle}>{paused ? 'Resume' : 'Pause'}</button>
700
+ * }
701
+ */
702
+ declare function usePause(): PauseControls;
703
+
558
704
  interface ContactOpts {
559
705
  /** Only fire if the other entity has this tag */
560
706
  tag?: string;
@@ -616,4 +762,4 @@ interface DevToolsHandle {
616
762
  onFrame?: () => void;
617
763
  }
618
764
 
619
- export { Animation, type BoundInputMap, BoxCollider, Camera2D, type CameraControls, Checkpoint, CircleCollider, type DevToolsHandle, type EngineState, Entity, Game, type GameControls, MovingPlatform, ParallaxLayer, ParticleEmitter, type ParticlePreset, type PlatformerControllerOptions, RigidBody, ScreenFlash, type ScreenFlashHandle, Script, type SnapshotControls, Sprite, type SpriteAtlas, SquashStretch, type TiledLayer, type TiledObject, Tilemap, type TopDownMovementOptions, Transform, World, createAtlas, useCamera, useCircleEnter, useCircleExit, useCollisionEnter, useCollisionExit, useEntity, useEvent, useEvents, useGame, useInput, useInputMap, usePlatformerController, useSnapshot, useTopDownMovement, useTriggerEnter, useTriggerExit };
765
+ export { Animation, type BoundInputMap, BoxCollider, Camera2D, type CameraControls, Checkpoint, CircleCollider, type DevToolsHandle, type EngineState, Entity, Game, type GameControls, type GameTimer, type GamepadState, MovingPlatform, ParallaxLayer, ParticleEmitter, type ParticlePreset, type PauseControls, type PlatformerControllerOptions, RigidBody, ScreenFlash, type ScreenFlashHandle, Script, type SnapshotControls, type SoundControls, Sprite, type SpriteAtlas, SquashStretch, Text, type TiledLayer, type TiledObject, Tilemap, type TopDownMovementOptions, Transform, World, createAtlas, createTimer, useCamera, useCircleEnter, useCircleExit, useCollisionEnter, useCollisionExit, useEntity, useEvent, useEvents, useGame, useGamepad, useInput, useInputMap, usePause, usePlatformerController, useSnapshot, useSound, useTopDownMovement, useTriggerEnter, useTriggerExit };
package/dist/index.js CHANGED
@@ -961,6 +961,14 @@ var RenderSystem = class {
961
961
  } else if (sprite.frame) {
962
962
  const { sx, sy, sw, sh } = sprite.frame;
963
963
  ctx.drawImage(sprite.image, sx, sy, sw, sh, drawX, drawY, sprite.width, sprite.height);
964
+ } else if (sprite.tileX || sprite.tileY) {
965
+ const repeat = sprite.tileX && sprite.tileY ? "repeat" : sprite.tileX ? "repeat-x" : "repeat-y";
966
+ const pat = ctx.createPattern(sprite.image, repeat);
967
+ if (pat) {
968
+ pat.setTransform(new DOMMatrix().translate(drawX, drawY));
969
+ ctx.fillStyle = pat;
970
+ ctx.fillRect(drawX, drawY, sprite.width, sprite.height);
971
+ }
964
972
  } else {
965
973
  ctx.drawImage(sprite.image, drawX, drawY, sprite.width, sprite.height);
966
974
  }
@@ -970,6 +978,26 @@ var RenderSystem = class {
970
978
  }
971
979
  ctx.restore();
972
980
  }
981
+ const textEntities = world.query("Transform", "Text");
982
+ textEntities.sort((a, b) => {
983
+ const ta = world.getComponent(a, "Text");
984
+ const tb = world.getComponent(b, "Text");
985
+ return ta.zIndex - tb.zIndex;
986
+ });
987
+ for (const id of textEntities) {
988
+ const transform = world.getComponent(id, "Transform");
989
+ const text = world.getComponent(id, "Text");
990
+ if (!text.visible) continue;
991
+ ctx.save();
992
+ ctx.translate(transform.x + text.offsetX, transform.y + text.offsetY);
993
+ ctx.rotate(transform.rotation);
994
+ ctx.font = `${text.fontSize}px ${text.fontFamily}`;
995
+ ctx.fillStyle = text.color;
996
+ ctx.textAlign = text.align;
997
+ ctx.textBaseline = text.baseline;
998
+ ctx.fillText(text.text, 0, 0, text.maxWidth);
999
+ ctx.restore();
1000
+ }
973
1001
  for (const id of world.query("Transform", "ParticlePool")) {
974
1002
  const t = world.getComponent(id, "Transform");
975
1003
  const pool = world.getComponent(id, "ParticlePool");
@@ -2201,7 +2229,9 @@ function Sprite({
2201
2229
  frameHeight,
2202
2230
  frameColumns,
2203
2231
  atlas,
2204
- frame
2232
+ frame,
2233
+ tileX,
2234
+ tileY
2205
2235
  }) {
2206
2236
  const resolvedFrameIndex = atlas && frame != null ? atlas[frame] ?? 0 : frameIndex;
2207
2237
  const engine = useContext4(EngineContext);
@@ -2222,7 +2252,9 @@ function Sprite({
2222
2252
  frameIndex: resolvedFrameIndex,
2223
2253
  frameWidth,
2224
2254
  frameHeight,
2225
- frameColumns
2255
+ frameColumns,
2256
+ tileX,
2257
+ tileY
2226
2258
  });
2227
2259
  engine.ecs.addComponent(entityId, comp);
2228
2260
  if (src) {
@@ -2248,8 +2280,54 @@ function Sprite({
2248
2280
  return null;
2249
2281
  }
2250
2282
 
2251
- // src/components/RigidBody.tsx
2283
+ // src/components/Text.tsx
2252
2284
  import { useEffect as useEffect7, useContext as useContext5 } from "react";
2285
+ function Text({
2286
+ text,
2287
+ fontSize = 16,
2288
+ fontFamily = "monospace",
2289
+ color = "#ffffff",
2290
+ align = "center",
2291
+ baseline = "middle",
2292
+ zIndex = 10,
2293
+ visible = true,
2294
+ maxWidth,
2295
+ offsetX = 0,
2296
+ offsetY = 0
2297
+ }) {
2298
+ const engine = useContext5(EngineContext);
2299
+ const entityId = useContext5(EntityContext);
2300
+ useEffect7(() => {
2301
+ const comp = {
2302
+ type: "Text",
2303
+ text,
2304
+ fontSize,
2305
+ fontFamily,
2306
+ color,
2307
+ align,
2308
+ baseline,
2309
+ zIndex,
2310
+ visible,
2311
+ maxWidth,
2312
+ offsetX,
2313
+ offsetY
2314
+ };
2315
+ engine.ecs.addComponent(entityId, comp);
2316
+ return () => engine.ecs.removeComponent(entityId, "Text");
2317
+ }, []);
2318
+ useEffect7(() => {
2319
+ const comp = engine.ecs.getComponent(entityId, "Text");
2320
+ if (!comp) return;
2321
+ comp.text = text;
2322
+ comp.color = color;
2323
+ comp.visible = visible;
2324
+ comp.zIndex = zIndex;
2325
+ }, [text, color, visible, zIndex, engine, entityId]);
2326
+ return null;
2327
+ }
2328
+
2329
+ // src/components/RigidBody.tsx
2330
+ import { useEffect as useEffect8, useContext as useContext6 } from "react";
2253
2331
  function RigidBody({
2254
2332
  mass = 1,
2255
2333
  gravityScale = 1,
@@ -2261,9 +2339,9 @@ function RigidBody({
2261
2339
  lockX = false,
2262
2340
  lockY = false
2263
2341
  }) {
2264
- const engine = useContext5(EngineContext);
2265
- const entityId = useContext5(EntityContext);
2266
- useEffect7(() => {
2342
+ const engine = useContext6(EngineContext);
2343
+ const entityId = useContext6(EntityContext);
2344
+ useEffect8(() => {
2267
2345
  engine.ecs.addComponent(entityId, createRigidBody({ mass, gravityScale, isStatic, bounce, friction, vx, vy, lockX, lockY }));
2268
2346
  return () => engine.ecs.removeComponent(entityId, "RigidBody");
2269
2347
  }, []);
@@ -2271,7 +2349,7 @@ function RigidBody({
2271
2349
  }
2272
2350
 
2273
2351
  // src/components/BoxCollider.tsx
2274
- import { useEffect as useEffect8, useContext as useContext6 } from "react";
2352
+ import { useEffect as useEffect9, useContext as useContext7 } from "react";
2275
2353
  function BoxCollider({
2276
2354
  width,
2277
2355
  height,
@@ -2282,9 +2360,9 @@ function BoxCollider({
2282
2360
  mask = "*",
2283
2361
  oneWay = false
2284
2362
  }) {
2285
- const engine = useContext6(EngineContext);
2286
- const entityId = useContext6(EntityContext);
2287
- useEffect8(() => {
2363
+ const engine = useContext7(EngineContext);
2364
+ const entityId = useContext7(EntityContext);
2365
+ useEffect9(() => {
2288
2366
  engine.ecs.addComponent(entityId, createBoxCollider(width, height, { offsetX, offsetY, isTrigger, layer, mask, oneWay }));
2289
2367
  const checkId = setTimeout(() => {
2290
2368
  if (engine.ecs.hasEntity(entityId) && !engine.ecs.hasComponent(entityId, "Transform")) {
@@ -2300,7 +2378,7 @@ function BoxCollider({
2300
2378
  }
2301
2379
 
2302
2380
  // src/components/CircleCollider.tsx
2303
- import { useEffect as useEffect9, useContext as useContext7 } from "react";
2381
+ import { useEffect as useEffect10, useContext as useContext8 } from "react";
2304
2382
  function CircleCollider({
2305
2383
  radius,
2306
2384
  offsetX = 0,
@@ -2309,9 +2387,9 @@ function CircleCollider({
2309
2387
  layer = "default",
2310
2388
  mask = "*"
2311
2389
  }) {
2312
- const engine = useContext7(EngineContext);
2313
- const entityId = useContext7(EntityContext);
2314
- useEffect9(() => {
2390
+ const engine = useContext8(EngineContext);
2391
+ const entityId = useContext8(EntityContext);
2392
+ useEffect10(() => {
2315
2393
  engine.ecs.addComponent(entityId, createCircleCollider(radius, { offsetX, offsetY, isTrigger, layer, mask }));
2316
2394
  return () => engine.ecs.removeComponent(entityId, "CircleCollider");
2317
2395
  }, []);
@@ -2319,11 +2397,11 @@ function CircleCollider({
2319
2397
  }
2320
2398
 
2321
2399
  // src/components/Script.tsx
2322
- import { useEffect as useEffect10, useContext as useContext8 } from "react";
2400
+ import { useEffect as useEffect11, useContext as useContext9 } from "react";
2323
2401
  function Script({ init, update }) {
2324
- const engine = useContext8(EngineContext);
2325
- const entityId = useContext8(EntityContext);
2326
- useEffect10(() => {
2402
+ const engine = useContext9(EngineContext);
2403
+ const entityId = useContext9(EntityContext);
2404
+ useEffect11(() => {
2327
2405
  if (init) {
2328
2406
  try {
2329
2407
  init(entityId, engine.ecs);
@@ -2338,7 +2416,7 @@ function Script({ init, update }) {
2338
2416
  }
2339
2417
 
2340
2418
  // src/components/Camera2D.tsx
2341
- import { useEffect as useEffect11, useContext as useContext9 } from "react";
2419
+ import { useEffect as useEffect12, useContext as useContext10 } from "react";
2342
2420
  function Camera2D({
2343
2421
  followEntity,
2344
2422
  x = 0,
@@ -2351,8 +2429,8 @@ function Camera2D({
2351
2429
  followOffsetX = 0,
2352
2430
  followOffsetY = 0
2353
2431
  }) {
2354
- const engine = useContext9(EngineContext);
2355
- useEffect11(() => {
2432
+ const engine = useContext10(EngineContext);
2433
+ useEffect12(() => {
2356
2434
  const entityId = engine.ecs.createEntity();
2357
2435
  engine.ecs.addComponent(entityId, createCamera2D({
2358
2436
  followEntityId: followEntity,
@@ -2368,7 +2446,7 @@ function Camera2D({
2368
2446
  }));
2369
2447
  return () => engine.ecs.destroyEntity(entityId);
2370
2448
  }, []);
2371
- useEffect11(() => {
2449
+ useEffect12(() => {
2372
2450
  const camId = engine.ecs.queryOne("Camera2D");
2373
2451
  if (camId === void 0) return;
2374
2452
  const cam = engine.ecs.getComponent(camId, "Camera2D");
@@ -2385,11 +2463,11 @@ function Camera2D({
2385
2463
  }
2386
2464
 
2387
2465
  // src/components/Animation.tsx
2388
- import { useEffect as useEffect12, useContext as useContext10 } from "react";
2466
+ import { useEffect as useEffect13, useContext as useContext11 } from "react";
2389
2467
  function Animation({ frames, fps = 12, loop = true, playing = true, onComplete }) {
2390
- const engine = useContext10(EngineContext);
2391
- const entityId = useContext10(EntityContext);
2392
- useEffect12(() => {
2468
+ const engine = useContext11(EngineContext);
2469
+ const entityId = useContext11(EntityContext);
2470
+ useEffect13(() => {
2393
2471
  const state = {
2394
2472
  type: "AnimationState",
2395
2473
  frames,
@@ -2406,7 +2484,7 @@ function Animation({ frames, fps = 12, loop = true, playing = true, onComplete }
2406
2484
  engine.ecs.removeComponent(entityId, "AnimationState");
2407
2485
  };
2408
2486
  }, []);
2409
- useEffect12(() => {
2487
+ useEffect13(() => {
2410
2488
  const anim = engine.ecs.getComponent(entityId, "AnimationState");
2411
2489
  if (!anim) return;
2412
2490
  const wasFramesChanged = anim.frames !== frames;
@@ -2425,11 +2503,11 @@ function Animation({ frames, fps = 12, loop = true, playing = true, onComplete }
2425
2503
  }
2426
2504
 
2427
2505
  // src/components/SquashStretch.tsx
2428
- import { useEffect as useEffect13, useContext as useContext11 } from "react";
2506
+ import { useEffect as useEffect14, useContext as useContext12 } from "react";
2429
2507
  function SquashStretch({ intensity = 0.2, recovery = 8 }) {
2430
- const engine = useContext11(EngineContext);
2431
- const entityId = useContext11(EntityContext);
2432
- useEffect13(() => {
2508
+ const engine = useContext12(EngineContext);
2509
+ const entityId = useContext12(EntityContext);
2510
+ useEffect14(() => {
2433
2511
  engine.ecs.addComponent(entityId, {
2434
2512
  type: "SquashStretch",
2435
2513
  intensity,
@@ -2443,7 +2521,7 @@ function SquashStretch({ intensity = 0.2, recovery = 8 }) {
2443
2521
  }
2444
2522
 
2445
2523
  // src/components/ParticleEmitter.tsx
2446
- import { useEffect as useEffect14, useContext as useContext12 } from "react";
2524
+ import { useEffect as useEffect15, useContext as useContext13 } from "react";
2447
2525
 
2448
2526
  // src/components/particlePresets.ts
2449
2527
  var PARTICLE_PRESETS = {
@@ -2528,9 +2606,9 @@ function ParticleEmitter({
2528
2606
  const resolvedColor = color ?? presetConfig.color ?? "#ffffff";
2529
2607
  const resolvedGravity = gravity ?? presetConfig.gravity ?? 200;
2530
2608
  const resolvedMaxParticles = maxParticles ?? presetConfig.maxParticles ?? 100;
2531
- const engine = useContext12(EngineContext);
2532
- const entityId = useContext12(EntityContext);
2533
- useEffect14(() => {
2609
+ const engine = useContext13(EngineContext);
2610
+ const entityId = useContext13(EntityContext);
2611
+ useEffect15(() => {
2534
2612
  engine.ecs.addComponent(entityId, {
2535
2613
  type: "ParticlePool",
2536
2614
  particles: [],
@@ -2548,7 +2626,7 @@ function ParticleEmitter({
2548
2626
  });
2549
2627
  return () => engine.ecs.removeComponent(entityId, "ParticlePool");
2550
2628
  }, []);
2551
- useEffect14(() => {
2629
+ useEffect15(() => {
2552
2630
  const pool = engine.ecs.getComponent(entityId, "ParticlePool");
2553
2631
  if (!pool) return;
2554
2632
  pool.active = active;
@@ -2598,13 +2676,13 @@ function MovingPlatform({
2598
2676
  import { useState as useState4 } from "react";
2599
2677
 
2600
2678
  // src/hooks/useContact.ts
2601
- import { useContext as useContext13, useEffect as useEffect15 } from "react";
2679
+ import { useContext as useContext14, useEffect as useEffect16 } from "react";
2602
2680
  function useContactEvent(eventName, handler, opts) {
2603
- const engine = useContext13(EngineContext);
2604
- const entityId = useContext13(EntityContext);
2681
+ const engine = useContext14(EngineContext);
2682
+ const entityId = useContext14(EntityContext);
2605
2683
  if (!engine) throw new Error(`${eventName} hook must be used inside <Game>`);
2606
2684
  if (entityId === null) throw new Error(`${eventName} hook must be used inside <Entity>`);
2607
- useEffect15(() => {
2685
+ useEffect16(() => {
2608
2686
  return engine.events.on(eventName, ({ a, b }) => {
2609
2687
  const isA = a === entityId;
2610
2688
  const isB = b === entityId;
@@ -2669,7 +2747,7 @@ function Checkpoint({
2669
2747
  }
2670
2748
 
2671
2749
  // src/components/Tilemap.tsx
2672
- import { useEffect as useEffect16, useState as useState5, useContext as useContext14 } from "react";
2750
+ import { useEffect as useEffect17, useState as useState5, useContext as useContext15 } from "react";
2673
2751
  import { Fragment as Fragment3, jsx as jsx7 } from "react/jsx-runtime";
2674
2752
  var animatedTiles = /* @__PURE__ */ new Map();
2675
2753
  function getProperty(props, name) {
@@ -2693,9 +2771,9 @@ function Tilemap({
2693
2771
  triggerLayer: triggerLayerName = "triggers",
2694
2772
  onTileProperty
2695
2773
  }) {
2696
- const engine = useContext14(EngineContext);
2774
+ const engine = useContext15(EngineContext);
2697
2775
  const [spawnedNodes, setSpawnedNodes] = useState5([]);
2698
- useEffect16(() => {
2776
+ useEffect17(() => {
2699
2777
  if (!engine) return;
2700
2778
  const createdEntities = [];
2701
2779
  async function load() {
@@ -2878,7 +2956,7 @@ function Tilemap({
2878
2956
  }
2879
2957
 
2880
2958
  // src/components/ParallaxLayer.tsx
2881
- import { useEffect as useEffect17, useContext as useContext15 } from "react";
2959
+ import { useEffect as useEffect18, useContext as useContext16 } from "react";
2882
2960
  import { jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
2883
2961
  function ParallaxLayerInner({
2884
2962
  src,
@@ -2890,9 +2968,9 @@ function ParallaxLayerInner({
2890
2968
  offsetX,
2891
2969
  offsetY
2892
2970
  }) {
2893
- const engine = useContext15(EngineContext);
2894
- const entityId = useContext15(EntityContext);
2895
- useEffect17(() => {
2971
+ const engine = useContext16(EngineContext);
2972
+ const entityId = useContext16(EntityContext);
2973
+ useEffect18(() => {
2896
2974
  engine.ecs.addComponent(entityId, {
2897
2975
  type: "ParallaxLayer",
2898
2976
  src,
@@ -2908,7 +2986,7 @@ function ParallaxLayerInner({
2908
2986
  });
2909
2987
  return () => engine.ecs.removeComponent(entityId, "ParallaxLayer");
2910
2988
  }, []);
2911
- useEffect17(() => {
2989
+ useEffect18(() => {
2912
2990
  const layer = engine.ecs.getComponent(entityId, "ParallaxLayer");
2913
2991
  if (!layer) return;
2914
2992
  layer.src = src;
@@ -2990,9 +3068,9 @@ var ScreenFlash = forwardRef((_, ref) => {
2990
3068
  ScreenFlash.displayName = "ScreenFlash";
2991
3069
 
2992
3070
  // src/hooks/useGame.ts
2993
- import { useContext as useContext16 } from "react";
3071
+ import { useContext as useContext17 } from "react";
2994
3072
  function useGame() {
2995
- const engine = useContext16(EngineContext);
3073
+ const engine = useContext17(EngineContext);
2996
3074
  if (!engine) throw new Error("useGame must be used inside <Game>");
2997
3075
  return engine;
2998
3076
  }
@@ -3043,17 +3121,17 @@ function useSnapshot() {
3043
3121
  }
3044
3122
 
3045
3123
  // src/hooks/useEntity.ts
3046
- import { useContext as useContext17 } from "react";
3124
+ import { useContext as useContext18 } from "react";
3047
3125
  function useEntity() {
3048
- const id = useContext17(EntityContext);
3126
+ const id = useContext18(EntityContext);
3049
3127
  if (id === null) throw new Error("useEntity must be used inside <Entity>");
3050
3128
  return id;
3051
3129
  }
3052
3130
 
3053
3131
  // src/hooks/useInput.ts
3054
- import { useContext as useContext18 } from "react";
3132
+ import { useContext as useContext19 } from "react";
3055
3133
  function useInput() {
3056
- const engine = useContext18(EngineContext);
3134
+ const engine = useContext19(EngineContext);
3057
3135
  if (!engine) throw new Error("useInput must be used inside <Game>");
3058
3136
  return engine.input;
3059
3137
  }
@@ -3077,32 +3155,46 @@ function useInputMap(bindings) {
3077
3155
  }
3078
3156
 
3079
3157
  // src/hooks/useEvents.ts
3080
- import { useContext as useContext19, useEffect as useEffect18 } from "react";
3158
+ import { useContext as useContext20, useEffect as useEffect19 } from "react";
3081
3159
  function useEvents() {
3082
- const engine = useContext19(EngineContext);
3160
+ const engine = useContext20(EngineContext);
3083
3161
  if (!engine) throw new Error("useEvents must be used inside <Game>");
3084
3162
  return engine.events;
3085
3163
  }
3086
3164
  function useEvent(event, handler) {
3087
3165
  const events = useEvents();
3088
- useEffect18(() => {
3166
+ useEffect19(() => {
3089
3167
  return events.on(event, handler);
3090
3168
  }, [events, event]);
3091
3169
  }
3092
3170
 
3093
3171
  // src/hooks/usePlatformerController.ts
3094
- import { useContext as useContext20, useEffect as useEffect19 } from "react";
3172
+ import { useContext as useContext21, useEffect as useEffect20 } from "react";
3173
+ function normalizeKeys(val, defaults) {
3174
+ if (!val) return defaults;
3175
+ return Array.isArray(val) ? val : [val];
3176
+ }
3095
3177
  function usePlatformerController(entityId, opts = {}) {
3096
- const engine = useContext20(EngineContext);
3178
+ const engine = useContext21(EngineContext);
3097
3179
  const {
3098
3180
  speed = 200,
3099
3181
  jumpForce = -500,
3100
3182
  maxJumps = 1,
3101
3183
  coyoteTime = 0.08,
3102
- jumpBuffer = 0.08
3184
+ jumpBuffer = 0.08,
3185
+ jumpCooldown = 0.18,
3186
+ bindings
3103
3187
  } = opts;
3104
- useEffect19(() => {
3105
- const state = { coyoteTimer: 0, jumpBuffer: 0, jumpsLeft: maxJumps };
3188
+ const leftKeys = normalizeKeys(bindings?.left, ["ArrowLeft", "KeyA", "a"]);
3189
+ const rightKeys = normalizeKeys(bindings?.right, ["ArrowRight", "KeyD", "d"]);
3190
+ const jumpKeys = normalizeKeys(bindings?.jump, ["Space", "ArrowUp", "KeyW", "w"]);
3191
+ useEffect20(() => {
3192
+ const state = {
3193
+ coyoteTimer: 0,
3194
+ jumpBuffer: 0,
3195
+ jumpCooldown: 0,
3196
+ jumpsLeft: maxJumps
3197
+ };
3106
3198
  const updateFn = (id, world, input, dt) => {
3107
3199
  if (!world.hasEntity(id)) return;
3108
3200
  const rb = world.getComponent(id, "RigidBody");
@@ -3111,11 +3203,12 @@ function usePlatformerController(entityId, opts = {}) {
3111
3203
  state.coyoteTimer = coyoteTime;
3112
3204
  state.jumpsLeft = maxJumps;
3113
3205
  } else state.coyoteTimer = Math.max(0, state.coyoteTimer - dt);
3114
- const jumpPressed = input.isPressed("Space") || input.isPressed("ArrowUp") || input.isPressed("KeyW") || input.isPressed("w");
3115
- if (jumpPressed) state.jumpBuffer = jumpBuffer;
3116
- else state.jumpBuffer = Math.max(0, state.jumpBuffer - dt);
3117
- const left = input.isDown("ArrowLeft") || input.isDown("KeyA") || input.isDown("a");
3118
- const right = input.isDown("ArrowRight") || input.isDown("KeyD") || input.isDown("d");
3206
+ state.jumpCooldown = Math.max(0, state.jumpCooldown - dt);
3207
+ const jumpPressed = jumpKeys.some((k) => input.isPressed(k));
3208
+ if (jumpPressed && state.jumpCooldown === 0) state.jumpBuffer = jumpBuffer;
3209
+ else if (!jumpKeys.some((k) => input.isDown(k))) state.jumpBuffer = Math.max(0, state.jumpBuffer - dt);
3210
+ const left = leftKeys.some((k) => input.isDown(k));
3211
+ const right = rightKeys.some((k) => input.isDown(k));
3119
3212
  if (left) rb.vx = -speed;
3120
3213
  else if (right) rb.vx = speed;
3121
3214
  else rb.vx *= rb.onGround ? 0.6 : 0.92;
@@ -3130,8 +3223,9 @@ function usePlatformerController(entityId, opts = {}) {
3130
3223
  state.jumpsLeft = Math.max(0, state.jumpsLeft - 1);
3131
3224
  state.coyoteTimer = 0;
3132
3225
  state.jumpBuffer = 0;
3226
+ state.jumpCooldown = jumpCooldown;
3133
3227
  }
3134
- const jumpHeld = input.isDown("Space") || input.isDown("ArrowUp") || input.isDown("KeyW") || input.isDown("w");
3228
+ const jumpHeld = jumpKeys.some((k) => input.isDown(k));
3135
3229
  if (!jumpHeld && rb.vy < -120) rb.vy += 800 * dt;
3136
3230
  };
3137
3231
  engine.ecs.addComponent(entityId, createScript(updateFn));
@@ -3140,11 +3234,11 @@ function usePlatformerController(entityId, opts = {}) {
3140
3234
  }
3141
3235
 
3142
3236
  // src/hooks/useTopDownMovement.ts
3143
- import { useContext as useContext21, useEffect as useEffect20 } from "react";
3237
+ import { useContext as useContext22, useEffect as useEffect21 } from "react";
3144
3238
  function useTopDownMovement(entityId, opts = {}) {
3145
- const engine = useContext21(EngineContext);
3239
+ const engine = useContext22(EngineContext);
3146
3240
  const { speed = 200, normalizeDiagonal = true } = opts;
3147
- useEffect20(() => {
3241
+ useEffect21(() => {
3148
3242
  const updateFn = (id, world, input) => {
3149
3243
  if (!world.hasEntity(id)) return;
3150
3244
  const rb = world.getComponent(id, "RigidBody");
@@ -3168,6 +3262,166 @@ function useTopDownMovement(entityId, opts = {}) {
3168
3262
  }, []);
3169
3263
  }
3170
3264
 
3265
+ // src/hooks/useSound.ts
3266
+ import { useEffect as useEffect22, useRef as useRef3 } from "react";
3267
+ var _audioCtx = null;
3268
+ function getAudioCtx() {
3269
+ if (!_audioCtx) _audioCtx = new AudioContext();
3270
+ return _audioCtx;
3271
+ }
3272
+ var bufferCache = /* @__PURE__ */ new Map();
3273
+ async function loadBuffer(src) {
3274
+ const cached = bufferCache.get(src);
3275
+ if (cached) return cached;
3276
+ const res = await fetch(src);
3277
+ const data = await res.arrayBuffer();
3278
+ const buf = await getAudioCtx().decodeAudioData(data);
3279
+ bufferCache.set(src, buf);
3280
+ return buf;
3281
+ }
3282
+ function useSound(src, opts = {}) {
3283
+ const bufferRef = useRef3(null);
3284
+ const sourceRef = useRef3(null);
3285
+ const gainRef = useRef3(null);
3286
+ const volRef = useRef3(opts.volume ?? 1);
3287
+ const loopRef = useRef3(opts.loop ?? false);
3288
+ useEffect22(() => {
3289
+ loadBuffer(src).then((buf) => {
3290
+ bufferRef.current = buf;
3291
+ }).catch(console.error);
3292
+ }, [src]);
3293
+ const play = () => {
3294
+ if (!bufferRef.current) return;
3295
+ const ctx = getAudioCtx();
3296
+ if (ctx.state === "suspended") ctx.resume();
3297
+ if (sourceRef.current) {
3298
+ try {
3299
+ sourceRef.current.stop();
3300
+ } catch {
3301
+ }
3302
+ sourceRef.current = null;
3303
+ }
3304
+ const gain = ctx.createGain();
3305
+ gain.gain.value = volRef.current;
3306
+ gain.connect(ctx.destination);
3307
+ gainRef.current = gain;
3308
+ const source = ctx.createBufferSource();
3309
+ source.buffer = bufferRef.current;
3310
+ source.loop = loopRef.current;
3311
+ source.connect(gain);
3312
+ source.start();
3313
+ source.onended = () => {
3314
+ sourceRef.current = null;
3315
+ };
3316
+ sourceRef.current = source;
3317
+ };
3318
+ const stop = () => {
3319
+ if (sourceRef.current) {
3320
+ try {
3321
+ sourceRef.current.stop();
3322
+ } catch {
3323
+ }
3324
+ sourceRef.current = null;
3325
+ }
3326
+ };
3327
+ const setVolume = (v) => {
3328
+ volRef.current = v;
3329
+ if (gainRef.current) gainRef.current.gain.value = v;
3330
+ };
3331
+ return { play, stop, setVolume };
3332
+ }
3333
+
3334
+ // src/hooks/useTimer.ts
3335
+ function createTimer(duration, onComplete, autoStart = false) {
3336
+ let _duration = duration;
3337
+ let _elapsed = 0;
3338
+ let _running = autoStart;
3339
+ return {
3340
+ update(dt) {
3341
+ if (!_running) return;
3342
+ _elapsed += dt;
3343
+ if (_elapsed >= _duration) {
3344
+ _elapsed = _duration;
3345
+ _running = false;
3346
+ onComplete?.();
3347
+ }
3348
+ },
3349
+ start() {
3350
+ _running = true;
3351
+ },
3352
+ stop() {
3353
+ _running = false;
3354
+ },
3355
+ reset(d) {
3356
+ _elapsed = 0;
3357
+ _running = false;
3358
+ if (d !== void 0) _duration = d;
3359
+ },
3360
+ restart() {
3361
+ _elapsed = 0;
3362
+ _running = true;
3363
+ },
3364
+ get running() {
3365
+ return _running;
3366
+ },
3367
+ get elapsed() {
3368
+ return _elapsed;
3369
+ },
3370
+ get remaining() {
3371
+ return Math.max(0, _duration - _elapsed);
3372
+ },
3373
+ get progress() {
3374
+ return _duration > 0 ? Math.min(1, _elapsed / _duration) : 1;
3375
+ }
3376
+ };
3377
+ }
3378
+
3379
+ // src/hooks/useGamepad.ts
3380
+ import { useEffect as useEffect23, useRef as useRef4, useState as useState6 } from "react";
3381
+ var EMPTY_STATE = { connected: false, axes: [], buttons: [] };
3382
+ function useGamepad(playerIndex = 0) {
3383
+ const [state, setState] = useState6(EMPTY_STATE);
3384
+ const rafRef = useRef4(0);
3385
+ useEffect23(() => {
3386
+ const poll = () => {
3387
+ const gp = navigator.getGamepads()[playerIndex];
3388
+ if (gp) {
3389
+ setState({
3390
+ connected: true,
3391
+ axes: Array.from(gp.axes),
3392
+ buttons: Array.from(gp.buttons).map((b) => b.pressed)
3393
+ });
3394
+ } else {
3395
+ setState((s) => s.connected ? EMPTY_STATE : s);
3396
+ }
3397
+ rafRef.current = requestAnimationFrame(poll);
3398
+ };
3399
+ rafRef.current = requestAnimationFrame(poll);
3400
+ return () => cancelAnimationFrame(rafRef.current);
3401
+ }, [playerIndex]);
3402
+ return state;
3403
+ }
3404
+
3405
+ // src/hooks/usePause.ts
3406
+ import { useContext as useContext23, useState as useState7, useCallback as useCallback2 } from "react";
3407
+ function usePause() {
3408
+ const engine = useContext23(EngineContext);
3409
+ const [paused, setPaused] = useState7(false);
3410
+ const pause = useCallback2(() => {
3411
+ engine.loop.pause();
3412
+ setPaused(true);
3413
+ }, [engine]);
3414
+ const resume = useCallback2(() => {
3415
+ engine.loop.resume();
3416
+ setPaused(false);
3417
+ }, [engine]);
3418
+ const toggle = useCallback2(() => {
3419
+ if (engine.loop.isPaused) resume();
3420
+ else pause();
3421
+ }, [engine, pause, resume]);
3422
+ return { paused, pause, resume, toggle };
3423
+ }
3424
+
3171
3425
  // src/components/spriteAtlas.ts
3172
3426
  function createAtlas(names, _columns) {
3173
3427
  const atlas = {};
@@ -3193,6 +3447,7 @@ export {
3193
3447
  Script,
3194
3448
  Sprite,
3195
3449
  SquashStretch,
3450
+ Text,
3196
3451
  Tilemap,
3197
3452
  Transform,
3198
3453
  World,
@@ -3200,6 +3455,7 @@ export {
3200
3455
  createInputMap,
3201
3456
  createSprite,
3202
3457
  createTag,
3458
+ createTimer,
3203
3459
  createTransform,
3204
3460
  definePlugin,
3205
3461
  findByTag,
@@ -3215,10 +3471,13 @@ export {
3215
3471
  useEvent,
3216
3472
  useEvents,
3217
3473
  useGame,
3474
+ useGamepad,
3218
3475
  useInput,
3219
3476
  useInputMap,
3477
+ usePause,
3220
3478
  usePlatformerController,
3221
3479
  useSnapshot,
3480
+ useSound,
3222
3481
  useTopDownMovement,
3223
3482
  useTriggerEnter,
3224
3483
  useTriggerExit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeforge",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "React-first 2D browser game engine",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {