cubeforge 0.2.1 → 0.3.0

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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # cubeforge
2
+
3
+ **Build browser games with React.**
4
+
5
+ ```tsx
6
+ <Game width={800} height={500} gravity={980}>
7
+ <World background="#1a1a2e">
8
+ <Camera2D followEntity="player" smoothing={0.85} />
9
+ <Entity id="player" tags={['player']}>
10
+ <Transform x={100} y={300} />
11
+ <Sprite width={32} height={48} color="#4fc3f7" />
12
+ <RigidBody />
13
+ <BoxCollider width={32} height={48} />
14
+ <Script update={playerUpdate} />
15
+ </Entity>
16
+ <Entity tags={['ground']}>
17
+ <Transform x={400} y={480} />
18
+ <Sprite width={800} height={32} color="#37474f" />
19
+ <RigidBody isStatic />
20
+ <BoxCollider width={800} height={32} />
21
+ </Entity>
22
+ </World>
23
+ </Game>
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install cubeforge react react-dom
30
+ ```
31
+
32
+ ## What's included
33
+
34
+ - **ECS** — archetype-based entity-component-system with query caching
35
+ - **Physics** — two-pass AABB, capsule colliders, kinematic bodies, one-way platforms, 60 Hz fixed timestep
36
+ - **Renderer** — WebGL2 instanced renderer by default; Canvas2D opt-in via `renderer={Canvas2DRenderSystem}`
37
+ - **Input** — keyboard, mouse, gamepad, per-player input maps, input contexts, recording/playback
38
+ - **Audio** — Web Audio API with volume groups, fade, crossfade, ducking (`useSound`)
39
+ - **Gameplay hooks** — `usePlatformerController`, `useTopDownMovement`, `useHealth`, `useSave`, `useGameStateMachine`, `useLevelTransition`, `usePathfinding`, `useAISteering`, and more
40
+ - **DevTools** — time-travel frame scrubber and entity inspector (`<Game devtools>`)
41
+ - **Deterministic mode** — seeded RNG for reproducible simulations (`<Game deterministic seed={n}>`)
42
+
43
+ ## Quick example
44
+
45
+ ```tsx
46
+ import {
47
+ Game, World, Entity, Transform, Sprite,
48
+ RigidBody, BoxCollider, Script,
49
+ usePlatformerController, useHealth, useSound,
50
+ } from 'cubeforge'
51
+
52
+ function Player() {
53
+ const id = useEntity()
54
+ usePlatformerController(id, { speed: 220, jumpForce: -520, maxJumps: 2 })
55
+ const { hp, takeDamage } = useHealth(5, { onDeath: () => console.log('dead') })
56
+ const jump = useSound('/jump.wav', { group: 'sfx' })
57
+ return null
58
+ }
59
+
60
+ export default function MyGame() {
61
+ return (
62
+ <Game width={800} height={500} gravity={980}>
63
+ <World background="#1a1a2e">
64
+ <Camera2D followEntity="player" smoothing={0.87} />
65
+ <Entity id="player" tags={['player']}>
66
+ <Transform x={100} y={300} />
67
+ <Sprite src="/player.png" width={32} height={48} />
68
+ <RigidBody />
69
+ <BoxCollider width={30} height={48} />
70
+ <Player />
71
+ </Entity>
72
+ </World>
73
+ </Game>
74
+ )
75
+ }
76
+ ```
77
+
78
+ ## Links
79
+
80
+ - [Documentation](https://cubeforge.dev)
81
+ - [Examples](https://github.com/1homsi/cubeforge-examples) — 10 runnable games
82
+ - [GitHub](https://github.com/1homsi/cubeforge)
83
+
84
+ ## License
85
+
86
+ MIT
package/dist/index.d.mts CHANGED
@@ -10,8 +10,9 @@ export { EngineState, useCircleEnter, useCircleExit, useCircleStay, useCollision
10
10
  export { AISteering, AnimationClip, AnimationControllerResult, BindingControls, GameState as GameStateDefinition, GameStateMachineResult, HealthControls, HealthOptions, KinematicBodyControls, LevelTransitionControls, PathfindingControls, PlatformerControllerOptions, RestartControls, SaveControls, SaveOptions, TopDownMovementOptions, TransitionOptions, TransitionType, useAISteering, useAnimationController, useDamageZone, useDropThrough, useGameStateMachine, useHealth, useKinematicBody, useLevelTransition, usePathfinding, usePersistedBindings, usePlatformerController, useRestart, useSave, useTopDownMovement } from '@cubeforge/gameplay';
11
11
  export { AudioGroup, SoundControls, duck, getGroupVolume, setGroupMute, setGroupVolume, setMasterVolume, stopGroup, useSound } from '@cubeforge/audio';
12
12
  export { DevToolsHandle } from '@cubeforge/devtools';
13
+ export { AnimationStateComponent, RenderSystem as Canvas2DRenderSystem, ParallaxLayerComponent, Particle, ParticlePoolComponent, SpriteComponent, SquashStretchComponent, TextComponent, TrailComponent, createSprite } from '@cubeforge/renderer';
14
+ export { WebGLRenderSystem } from '@cubeforge/webgl-renderer';
13
15
  export { BoxColliderComponent, CapsuleColliderComponent, CircleColliderComponent, RaycastHit, RigidBodyComponent, overlapBox, overlapCircle, raycast, raycastAll, sweepBox } from '@cubeforge/physics';
14
- export { AnimationStateComponent, ParallaxLayerComponent, Particle, ParticlePoolComponent, SpriteComponent, SquashStretchComponent, TextComponent, TrailComponent, createSprite } from '@cubeforge/renderer';
15
16
 
16
17
  interface GameControls {
17
18
  pause(): void;
@@ -50,13 +51,12 @@ interface GameProps {
50
51
  /** Custom plugins to register after core systems. Each plugin's systems run after Render. */
51
52
  plugins?: Plugin[];
52
53
  /**
53
- * Custom render system constructor. Must implement the System interface and accept
54
- * `(canvas: HTMLCanvasElement, entityIds: Map<string, EntityId>)`.
55
- *
56
- * Defaults to the built-in Canvas2D RenderSystem.
57
- * Example: `import { WebGLRenderSystem } from '@cubeforge/webgl-renderer'`
54
+ * Renderer to use (default: WebGL2).
55
+ * - omit or undefined — WebGL2 instanced renderer (default)
56
+ * - 'canvas2d' — Canvas2D renderer (opt-in for compatibility or pixel art)
57
+ * - CustomClass any class implementing System with (canvas, entityIds) constructor
58
58
  */
59
- renderer?: new (canvas: HTMLCanvasElement, entityIds: Map<string, EntityId>) => System;
59
+ renderer?: 'canvas2d' | (new (canvas: HTMLCanvasElement, entityIds: Map<string, EntityId>) => System);
60
60
  style?: CSSProperties;
61
61
  className?: string;
62
62
  children?: React__default.ReactNode;
package/dist/index.js CHANGED
@@ -2795,6 +2795,7 @@ var DebugSystem = class {
2795
2795
  fps = 0;
2796
2796
  update(world, dt) {
2797
2797
  const { ctx, canvas } = this.renderer;
2798
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2798
2799
  this.frameCount++;
2799
2800
  this.lastFpsTime += dt;
2800
2801
  if (this.lastFpsTime >= 0.5) {
@@ -2887,6 +2888,816 @@ var DebugSystem = class {
2887
2888
  }
2888
2889
  };
2889
2890
 
2891
+ // ../../packages/webgl-renderer/src/shaders.ts
2892
+ var VERT_SRC = `#version 300 es
2893
+ layout(location = 0) in vec2 a_quadPos;
2894
+ layout(location = 1) in vec2 a_uv;
2895
+
2896
+ layout(location = 2) in vec2 i_pos;
2897
+ layout(location = 3) in vec2 i_size;
2898
+ layout(location = 4) in float i_rot;
2899
+ layout(location = 5) in vec2 i_anchor;
2900
+ layout(location = 6) in vec2 i_offset;
2901
+ layout(location = 7) in float i_flipX;
2902
+ layout(location = 8) in vec4 i_color;
2903
+ layout(location = 9) in vec4 i_uvRect;
2904
+
2905
+ uniform vec2 u_camPos;
2906
+ uniform float u_zoom;
2907
+ uniform vec2 u_canvasSize;
2908
+ uniform vec2 u_shake;
2909
+
2910
+ out vec2 v_uv;
2911
+ out vec4 v_color;
2912
+
2913
+ void main() {
2914
+ // Local position: map quad corner (-0.5..0.5) to draw rect, applying anchor
2915
+ vec2 local = (a_quadPos - vec2(i_anchor.x - 0.5, i_anchor.y - 0.5)) * i_size + i_offset;
2916
+
2917
+ // Horizontal flip
2918
+ if (i_flipX > 0.5) local.x = -local.x;
2919
+
2920
+ // Rotate around local origin
2921
+ float c = cos(i_rot);
2922
+ float s = sin(i_rot);
2923
+ local = vec2(c * local.x - s * local.y, s * local.x + c * local.y);
2924
+
2925
+ // World position
2926
+ vec2 world = i_pos + local;
2927
+
2928
+ // Camera \u2192 NDC clip space (Y is flipped: canvas Y down, WebGL Y up)
2929
+ // Equivalent to Canvas2D: translate(W/2 - camX*zoom + shakeX, H/2 - camY*zoom + shakeY); scale(zoom,zoom)
2930
+ float cx = 2.0 * u_zoom / u_canvasSize.x * (world.x - u_camPos.x) + 2.0 * u_shake.x / u_canvasSize.x;
2931
+ float cy = -2.0 * u_zoom / u_canvasSize.y * (world.y - u_camPos.y) - 2.0 * u_shake.y / u_canvasSize.y;
2932
+
2933
+ gl_Position = vec4(cx, cy, 0.0, 1.0);
2934
+
2935
+ // Remap UV [0,1] to the sub-rect defined by i_uvRect
2936
+ v_uv = i_uvRect.xy + a_uv * i_uvRect.zw;
2937
+ v_color = i_color;
2938
+ }
2939
+ `;
2940
+ var FRAG_SRC = `#version 300 es
2941
+ precision mediump float;
2942
+
2943
+ in vec2 v_uv;
2944
+ in vec4 v_color;
2945
+
2946
+ uniform sampler2D u_texture;
2947
+ uniform int u_useTexture;
2948
+
2949
+ out vec4 fragColor;
2950
+
2951
+ void main() {
2952
+ if (u_useTexture == 1) {
2953
+ fragColor = texture(u_texture, v_uv) * v_color;
2954
+ } else {
2955
+ fragColor = v_color;
2956
+ }
2957
+ }
2958
+ `;
2959
+ var PARALLAX_VERT_SRC = `#version 300 es
2960
+ layout(location = 0) in vec2 a_pos;
2961
+
2962
+ out vec2 v_fragCoord;
2963
+
2964
+ void main() {
2965
+ gl_Position = vec4(a_pos, 0.0, 1.0);
2966
+ // Convert NDC (-1..1) to canvas pixel coords (0..canvasSize) in the frag shader
2967
+ v_fragCoord = a_pos * 0.5 + 0.5; // 0..1 normalized screen coord
2968
+ }
2969
+ `;
2970
+ var PARALLAX_FRAG_SRC = `#version 300 es
2971
+ precision mediump float;
2972
+
2973
+ in vec2 v_fragCoord;
2974
+
2975
+ uniform sampler2D u_texture;
2976
+ uniform vec2 u_uvOffset;
2977
+ uniform vec2 u_texSize; // texture size in pixels
2978
+ uniform vec2 u_canvasSize; // canvas size in pixels
2979
+
2980
+ out vec4 fragColor;
2981
+
2982
+ void main() {
2983
+ // Screen pixel position
2984
+ vec2 screenPx = v_fragCoord * u_canvasSize;
2985
+ // Tile: offset by uvOffset and wrap
2986
+ vec2 uv = mod((screenPx / u_texSize + u_uvOffset), 1.0);
2987
+ // Y must be flipped because WebGL origin is bottom-left but canvas is top-left
2988
+ uv.y = 1.0 - uv.y;
2989
+ fragColor = texture(u_texture, uv);
2990
+ }
2991
+ `;
2992
+
2993
+ // ../../packages/webgl-renderer/src/colorParser.ts
2994
+ var cache = /* @__PURE__ */ new Map();
2995
+ function parseCSSColor(css) {
2996
+ const hit = cache.get(css);
2997
+ if (hit) return hit;
2998
+ let result = [1, 1, 1, 1];
2999
+ if (css.startsWith("#")) {
3000
+ const h = css.slice(1);
3001
+ if (h.length === 3 || h.length === 4) {
3002
+ const r = parseInt(h[0] + h[0], 16) / 255;
3003
+ const g = parseInt(h[1] + h[1], 16) / 255;
3004
+ const b = parseInt(h[2] + h[2], 16) / 255;
3005
+ const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
3006
+ result = [r, g, b, a];
3007
+ } else if (h.length === 6 || h.length === 8) {
3008
+ const r = parseInt(h.slice(0, 2), 16) / 255;
3009
+ const g = parseInt(h.slice(2, 4), 16) / 255;
3010
+ const b = parseInt(h.slice(4, 6), 16) / 255;
3011
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
3012
+ result = [r, g, b, a];
3013
+ }
3014
+ } else {
3015
+ const m = css.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
3016
+ if (m) {
3017
+ result = [
3018
+ parseInt(m[1]) / 255,
3019
+ parseInt(m[2]) / 255,
3020
+ parseInt(m[3]) / 255,
3021
+ m[4] !== void 0 ? parseFloat(m[4]) : 1
3022
+ ];
3023
+ }
3024
+ }
3025
+ cache.set(css, result);
3026
+ return result;
3027
+ }
3028
+
3029
+ // ../../packages/webgl-renderer/src/webglRenderSystem.ts
3030
+ var FLOATS_PER_INSTANCE = 18;
3031
+ var MAX_INSTANCES = 8192;
3032
+ var MAX_TEXT_CACHE = 200;
3033
+ function compileShader(gl, type, src) {
3034
+ const shader = gl.createShader(type);
3035
+ gl.shaderSource(shader, src);
3036
+ gl.compileShader(shader);
3037
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
3038
+ throw new Error(`[WebGLRenderer] Shader compile error:
3039
+ ${gl.getShaderInfoLog(shader)}`);
3040
+ }
3041
+ return shader;
3042
+ }
3043
+ function createProgram(gl, vertSrc, fragSrc) {
3044
+ const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
3045
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
3046
+ const prog = gl.createProgram();
3047
+ gl.attachShader(prog, vert);
3048
+ gl.attachShader(prog, frag);
3049
+ gl.linkProgram(prog);
3050
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
3051
+ throw new Error(`[WebGLRenderer] Program link error:
3052
+ ${gl.getProgramInfoLog(prog)}`);
3053
+ }
3054
+ gl.deleteShader(vert);
3055
+ gl.deleteShader(frag);
3056
+ return prog;
3057
+ }
3058
+ function createWhiteTexture(gl) {
3059
+ const tex = gl.createTexture();
3060
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3061
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]));
3062
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
3063
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
3064
+ return tex;
3065
+ }
3066
+ function getTextureKey(sprite) {
3067
+ if (sprite.image?.src) return sprite.image.src;
3068
+ if (sprite.src) return sprite.src;
3069
+ return `__color__:${sprite.color}`;
3070
+ }
3071
+ function getUVRect(sprite) {
3072
+ if (!sprite.image || sprite.image.naturalWidth === 0) return [0, 0, 1, 1];
3073
+ const iw = sprite.image.naturalWidth;
3074
+ const ih = sprite.image.naturalHeight;
3075
+ if (sprite.frameWidth && sprite.frameHeight) {
3076
+ const cols = sprite.frameColumns ?? Math.floor(iw / sprite.frameWidth);
3077
+ const col = sprite.frameIndex % cols;
3078
+ const row = Math.floor(sprite.frameIndex / cols);
3079
+ return [
3080
+ col * sprite.frameWidth / iw,
3081
+ row * sprite.frameHeight / ih,
3082
+ sprite.frameWidth / iw,
3083
+ sprite.frameHeight / ih
3084
+ ];
3085
+ }
3086
+ if (sprite.frame) {
3087
+ const { sx, sy, sw, sh } = sprite.frame;
3088
+ return [sx / iw, sy / ih, sw / iw, sh / ih];
3089
+ }
3090
+ return [0, 0, 1, 1];
3091
+ }
3092
+ var WebGLRenderSystem = class {
3093
+ constructor(canvas, entityIds) {
3094
+ this.canvas = canvas;
3095
+ this.entityIds = entityIds;
3096
+ const gl = canvas.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false });
3097
+ if (!gl) throw new Error("[WebGLRenderer] WebGL2 is not supported in this browser");
3098
+ this.gl = gl;
3099
+ this.program = createProgram(gl, VERT_SRC, FRAG_SRC);
3100
+ const quadVerts = new Float32Array([
3101
+ -0.5,
3102
+ -0.5,
3103
+ 0,
3104
+ 0,
3105
+ 0.5,
3106
+ -0.5,
3107
+ 1,
3108
+ 0,
3109
+ -0.5,
3110
+ 0.5,
3111
+ 0,
3112
+ 1,
3113
+ 0.5,
3114
+ -0.5,
3115
+ 1,
3116
+ 0,
3117
+ 0.5,
3118
+ 0.5,
3119
+ 1,
3120
+ 1,
3121
+ -0.5,
3122
+ 0.5,
3123
+ 0,
3124
+ 1
3125
+ ]);
3126
+ this.quadVAO = gl.createVertexArray();
3127
+ gl.bindVertexArray(this.quadVAO);
3128
+ const quadBuf = gl.createBuffer();
3129
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
3130
+ gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW);
3131
+ const qStride = 4 * 4;
3132
+ gl.enableVertexAttribArray(0);
3133
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, qStride, 0);
3134
+ gl.enableVertexAttribArray(1);
3135
+ gl.vertexAttribPointer(1, 2, gl.FLOAT, false, qStride, 2 * 4);
3136
+ this.instanceData = new Float32Array(MAX_INSTANCES * FLOATS_PER_INSTANCE);
3137
+ this.instanceBuffer = gl.createBuffer();
3138
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
3139
+ gl.bufferData(gl.ARRAY_BUFFER, this.instanceData.byteLength, gl.DYNAMIC_DRAW);
3140
+ const iStride = FLOATS_PER_INSTANCE * 4;
3141
+ let byteOffset = 0;
3142
+ const addAttr = (loc, size) => {
3143
+ gl.enableVertexAttribArray(loc);
3144
+ gl.vertexAttribPointer(loc, size, gl.FLOAT, false, iStride, byteOffset);
3145
+ gl.vertexAttribDivisor(loc, 1);
3146
+ byteOffset += size * 4;
3147
+ };
3148
+ addAttr(2, 2);
3149
+ addAttr(3, 2);
3150
+ addAttr(4, 1);
3151
+ addAttr(5, 2);
3152
+ addAttr(6, 2);
3153
+ addAttr(7, 1);
3154
+ addAttr(8, 4);
3155
+ addAttr(9, 4);
3156
+ gl.bindVertexArray(null);
3157
+ gl.useProgram(this.program);
3158
+ this.uCamPos = gl.getUniformLocation(this.program, "u_camPos");
3159
+ this.uZoom = gl.getUniformLocation(this.program, "u_zoom");
3160
+ this.uCanvasSize = gl.getUniformLocation(this.program, "u_canvasSize");
3161
+ this.uShake = gl.getUniformLocation(this.program, "u_shake");
3162
+ this.uTexture = gl.getUniformLocation(this.program, "u_texture");
3163
+ this.uUseTexture = gl.getUniformLocation(this.program, "u_useTexture");
3164
+ this.whiteTexture = createWhiteTexture(gl);
3165
+ this.parallaxProgram = createProgram(gl, PARALLAX_VERT_SRC, PARALLAX_FRAG_SRC);
3166
+ const fsVerts = new Float32Array([
3167
+ -1,
3168
+ -1,
3169
+ 1,
3170
+ -1,
3171
+ -1,
3172
+ 1,
3173
+ 1,
3174
+ -1,
3175
+ 1,
3176
+ 1,
3177
+ -1,
3178
+ 1
3179
+ ]);
3180
+ this.parallaxVAO = gl.createVertexArray();
3181
+ gl.bindVertexArray(this.parallaxVAO);
3182
+ const fsBuf = gl.createBuffer();
3183
+ gl.bindBuffer(gl.ARRAY_BUFFER, fsBuf);
3184
+ gl.bufferData(gl.ARRAY_BUFFER, fsVerts, gl.STATIC_DRAW);
3185
+ gl.enableVertexAttribArray(0);
3186
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0);
3187
+ gl.bindVertexArray(null);
3188
+ gl.useProgram(this.parallaxProgram);
3189
+ this.pUTexture = gl.getUniformLocation(this.parallaxProgram, "u_texture");
3190
+ this.pUUvOffset = gl.getUniformLocation(this.parallaxProgram, "u_uvOffset");
3191
+ this.pUTexSize = gl.getUniformLocation(this.parallaxProgram, "u_texSize");
3192
+ this.pUCanvasSize = gl.getUniformLocation(this.parallaxProgram, "u_canvasSize");
3193
+ gl.enable(gl.BLEND);
3194
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
3195
+ }
3196
+ gl;
3197
+ program;
3198
+ quadVAO;
3199
+ instanceBuffer;
3200
+ instanceData;
3201
+ whiteTexture;
3202
+ textures = /* @__PURE__ */ new Map();
3203
+ imageCache = /* @__PURE__ */ new Map();
3204
+ // Cached uniform locations — sprite program
3205
+ uCamPos;
3206
+ uZoom;
3207
+ uCanvasSize;
3208
+ uShake;
3209
+ uTexture;
3210
+ uUseTexture;
3211
+ // ── Parallax program ──────────────────────────────────────────────────────
3212
+ parallaxProgram;
3213
+ parallaxVAO;
3214
+ parallaxTextures = /* @__PURE__ */ new Map();
3215
+ parallaxImageCache = /* @__PURE__ */ new Map();
3216
+ // Cached uniform locations — parallax program
3217
+ pUTexture;
3218
+ pUUvOffset;
3219
+ pUTexSize;
3220
+ pUCanvasSize;
3221
+ // ── Text texture cache ────────────────────────────────────────────────────
3222
+ textureCache = /* @__PURE__ */ new Map();
3223
+ /** Insertion-order key list for LRU-style eviction. */
3224
+ textureCacheKeys = [];
3225
+ // ── Texture management (sprite textures — CLAMP_TO_EDGE) ──────────────────
3226
+ loadTexture(src) {
3227
+ const cached = this.textures.get(src);
3228
+ if (cached) return cached;
3229
+ let img = this.imageCache.get(src);
3230
+ if (!img) {
3231
+ img = new Image();
3232
+ img.src = src;
3233
+ img.onload = () => {
3234
+ const tex = this.gl.createTexture();
3235
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
3236
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
3237
+ this.gl.generateMipmap(this.gl.TEXTURE_2D);
3238
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
3239
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
3240
+ this.textures.set(src, tex);
3241
+ };
3242
+ this.imageCache.set(src, img);
3243
+ }
3244
+ return this.whiteTexture;
3245
+ }
3246
+ // ── Parallax texture management (REPEAT wrap mode) ────────────────────────
3247
+ loadParallaxTexture(src) {
3248
+ const cached = this.parallaxTextures.get(src);
3249
+ if (cached) return cached;
3250
+ let img = this.parallaxImageCache.get(src);
3251
+ if (!img) {
3252
+ img = new Image();
3253
+ img.src = src;
3254
+ img.onload = () => {
3255
+ const gl = this.gl;
3256
+ const tex = gl.createTexture();
3257
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3258
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
3259
+ gl.generateMipmap(gl.TEXTURE_2D);
3260
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
3261
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
3262
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
3263
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
3264
+ this.parallaxTextures.set(src, tex);
3265
+ };
3266
+ this.parallaxImageCache.set(src, img);
3267
+ }
3268
+ return null;
3269
+ }
3270
+ // ── Text texture management ───────────────────────────────────────────────
3271
+ getTextTextureKey(text) {
3272
+ return `${text.text}|${text.fontSize ?? 16}|${text.fontFamily ?? "monospace"}|${text.color ?? "#ffffff"}`;
3273
+ }
3274
+ getOrCreateTextTexture(text) {
3275
+ const key = this.getTextTextureKey(text);
3276
+ const cached = this.textureCache.get(key);
3277
+ if (cached) return cached;
3278
+ if (this.textureCache.size >= MAX_TEXT_CACHE) {
3279
+ const oldest = this.textureCacheKeys.shift();
3280
+ if (oldest) {
3281
+ const old = this.textureCache.get(oldest);
3282
+ if (old) this.gl.deleteTexture(old.tex);
3283
+ this.textureCache.delete(oldest);
3284
+ }
3285
+ }
3286
+ const offscreen = document.createElement("canvas");
3287
+ const ctx2d = offscreen.getContext("2d");
3288
+ const font = `${text.fontSize ?? 16}px ${text.fontFamily ?? "monospace"}`;
3289
+ ctx2d.font = font;
3290
+ const metrics = ctx2d.measureText(text.text);
3291
+ const textW = Math.ceil(metrics.width) + 4;
3292
+ const textH = Math.ceil((text.fontSize ?? 16) * 1.5) + 4;
3293
+ offscreen.width = textW;
3294
+ offscreen.height = textH;
3295
+ ctx2d.font = font;
3296
+ ctx2d.fillStyle = text.color ?? "#ffffff";
3297
+ ctx2d.textAlign = "left";
3298
+ ctx2d.textBaseline = "top";
3299
+ ctx2d.fillText(text.text, 2, 2, text.maxWidth);
3300
+ const gl = this.gl;
3301
+ const tex = gl.createTexture();
3302
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3303
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, offscreen);
3304
+ gl.generateMipmap(gl.TEXTURE_2D);
3305
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
3306
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
3307
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3308
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3309
+ const entry = { tex, w: textW, h: textH };
3310
+ this.textureCache.set(key, entry);
3311
+ this.textureCacheKeys.push(key);
3312
+ return entry;
3313
+ }
3314
+ // ── Instanced draw call ────────────────────────────────────────────────────
3315
+ flush(count, textureKey) {
3316
+ if (count === 0) return;
3317
+ const { gl } = this;
3318
+ const isColor = textureKey.startsWith("__color__");
3319
+ const tex = isColor ? this.whiteTexture : this.loadTexture(textureKey);
3320
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3321
+ gl.uniform1i(this.uUseTexture, isColor ? 0 : 1);
3322
+ gl.bindVertexArray(this.quadVAO);
3323
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
3324
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
3325
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
3326
+ }
3327
+ flushWithTex(count, tex, useTexture) {
3328
+ if (count === 0) return;
3329
+ const { gl } = this;
3330
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3331
+ gl.uniform1i(this.uUseTexture, useTexture ? 1 : 0);
3332
+ gl.bindVertexArray(this.quadVAO);
3333
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
3334
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
3335
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
3336
+ }
3337
+ // ── Write one sprite instance into instanceData ───────────────────────────
3338
+ writeInstance(base, x, y, w, h, rot, anchorX, anchorY, offsetX, offsetY, flipX, r, g, b, a, u, v, uw, vh) {
3339
+ const d = this.instanceData;
3340
+ d[base + 0] = x;
3341
+ d[base + 1] = y;
3342
+ d[base + 2] = w;
3343
+ d[base + 3] = h;
3344
+ d[base + 4] = rot;
3345
+ d[base + 5] = anchorX;
3346
+ d[base + 6] = anchorY;
3347
+ d[base + 7] = offsetX;
3348
+ d[base + 8] = offsetY;
3349
+ d[base + 9] = flipX ? 1 : 0;
3350
+ d[base + 10] = r;
3351
+ d[base + 11] = g;
3352
+ d[base + 12] = b;
3353
+ d[base + 13] = a;
3354
+ d[base + 14] = u;
3355
+ d[base + 15] = v;
3356
+ d[base + 16] = uw;
3357
+ d[base + 17] = vh;
3358
+ }
3359
+ // ── Main update loop ───────────────────────────────────────────────────────
3360
+ update(world, dt) {
3361
+ const { gl, canvas } = this;
3362
+ const W = canvas.width;
3363
+ const H = canvas.height;
3364
+ let camX = 0, camY = 0, zoom = 1;
3365
+ let background = "#000000";
3366
+ let shakeX = 0, shakeY = 0;
3367
+ const camId = world.queryOne("Camera2D");
3368
+ if (camId !== void 0) {
3369
+ const cam = world.getComponent(camId, "Camera2D");
3370
+ background = cam.background;
3371
+ if (cam.followEntityId) {
3372
+ const targetId = this.entityIds.get(cam.followEntityId);
3373
+ if (targetId !== void 0) {
3374
+ const t = world.getComponent(targetId, "Transform");
3375
+ if (t) {
3376
+ if (cam.deadZone) {
3377
+ const halfW = cam.deadZone.w / 2;
3378
+ const halfH = cam.deadZone.h / 2;
3379
+ const dx = t.x - cam.x, dy = t.y - cam.y;
3380
+ if (dx > halfW) cam.x = t.x - halfW;
3381
+ else if (dx < -halfW) cam.x = t.x + halfW;
3382
+ if (dy > halfH) cam.y = t.y - halfH;
3383
+ else if (dy < -halfH) cam.y = t.y + halfH;
3384
+ } else if (cam.smoothing > 0) {
3385
+ cam.x += (t.x - cam.x) * (1 - cam.smoothing);
3386
+ cam.y += (t.y - cam.y) * (1 - cam.smoothing);
3387
+ } else {
3388
+ cam.x = t.x;
3389
+ cam.y = t.y;
3390
+ }
3391
+ }
3392
+ }
3393
+ }
3394
+ if (cam.bounds) {
3395
+ const halfW = W / (2 * cam.zoom);
3396
+ const halfH = H / (2 * cam.zoom);
3397
+ cam.x = Math.max(cam.bounds.x + halfW, Math.min(cam.bounds.x + cam.bounds.width - halfW, cam.x));
3398
+ cam.y = Math.max(cam.bounds.y + halfH, Math.min(cam.bounds.y + cam.bounds.height - halfH, cam.y));
3399
+ }
3400
+ if (cam.shakeTimer > 0) {
3401
+ cam.shakeTimer -= dt;
3402
+ if (cam.shakeTimer < 0) cam.shakeTimer = 0;
3403
+ const progress = cam.shakeDuration > 0 ? cam.shakeTimer / cam.shakeDuration : 0;
3404
+ shakeX = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
3405
+ shakeY = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
3406
+ }
3407
+ camX = cam.x;
3408
+ camY = cam.y;
3409
+ zoom = cam.zoom;
3410
+ }
3411
+ for (const id of world.query("AnimationState", "Sprite")) {
3412
+ const anim = world.getComponent(id, "AnimationState");
3413
+ const sprite = world.getComponent(id, "Sprite");
3414
+ if (!anim.playing || anim.frames.length === 0) continue;
3415
+ anim.timer += dt;
3416
+ const frameDuration = 1 / anim.fps;
3417
+ while (anim.timer >= frameDuration) {
3418
+ anim.timer -= frameDuration;
3419
+ anim.currentIndex++;
3420
+ if (anim.currentIndex >= anim.frames.length) {
3421
+ anim.currentIndex = anim.loop ? 0 : anim.frames.length - 1;
3422
+ }
3423
+ }
3424
+ sprite.frameIndex = anim.frames[anim.currentIndex];
3425
+ }
3426
+ for (const id of world.query("SquashStretch", "RigidBody")) {
3427
+ const ss = world.getComponent(id, "SquashStretch");
3428
+ const rb = world.getComponent(id, "RigidBody");
3429
+ const spd = Math.sqrt(rb.vx * rb.vx + rb.vy * rb.vy);
3430
+ const tScX = rb.vy < -100 ? 1 + ss.intensity * 0.4 : spd > 50 ? 1 - ss.intensity * 0.3 : 1;
3431
+ const tScY = rb.vy < -100 ? 1 - ss.intensity * 0.4 : spd > 50 ? 1 + ss.intensity * 0.3 : 1;
3432
+ ss.currentScaleX += (tScX - ss.currentScaleX) * ss.recovery * dt;
3433
+ ss.currentScaleY += (tScY - ss.currentScaleY) * ss.recovery * dt;
3434
+ }
3435
+ const [br, bg, bb] = parseCSSColor(background);
3436
+ gl.viewport(0, 0, W, H);
3437
+ gl.clearColor(br, bg, bb, 1);
3438
+ gl.clear(gl.COLOR_BUFFER_BIT);
3439
+ const parallaxEntities = world.query("ParallaxLayer");
3440
+ if (parallaxEntities.length > 0) {
3441
+ parallaxEntities.sort((a, b) => {
3442
+ const za = world.getComponent(a, "ParallaxLayer").zIndex;
3443
+ const zb = world.getComponent(b, "ParallaxLayer").zIndex;
3444
+ return za - zb;
3445
+ });
3446
+ gl.useProgram(this.parallaxProgram);
3447
+ gl.uniform2f(this.pUCanvasSize, W, H);
3448
+ gl.uniform1i(this.pUTexture, 0);
3449
+ gl.activeTexture(gl.TEXTURE0);
3450
+ gl.bindVertexArray(this.parallaxVAO);
3451
+ for (const id of parallaxEntities) {
3452
+ const layer = world.getComponent(id, "ParallaxLayer");
3453
+ let img = this.parallaxImageCache.get(layer.src);
3454
+ if (!img) {
3455
+ this.loadParallaxTexture(layer.src);
3456
+ continue;
3457
+ }
3458
+ if (!img.complete || img.naturalWidth === 0) continue;
3459
+ if (layer.imageWidth === 0) layer.imageWidth = img.naturalWidth;
3460
+ if (layer.imageHeight === 0) layer.imageHeight = img.naturalHeight;
3461
+ const tex = this.parallaxTextures.get(layer.src);
3462
+ if (!tex) continue;
3463
+ const imgW = layer.imageWidth;
3464
+ const imgH = layer.imageHeight;
3465
+ const drawX = layer.offsetX - camX * layer.speedX;
3466
+ const drawY = layer.offsetY - camY * layer.speedY;
3467
+ const uvOffsetX = (drawX / imgW % 1 + 1) % 1;
3468
+ const uvOffsetY = (drawY / imgH % 1 + 1) % 1;
3469
+ gl.bindTexture(gl.TEXTURE_2D, tex);
3470
+ gl.uniform2f(this.pUUvOffset, uvOffsetX, uvOffsetY);
3471
+ gl.uniform2f(this.pUTexSize, imgW, imgH);
3472
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
3473
+ }
3474
+ }
3475
+ gl.useProgram(this.program);
3476
+ gl.uniform2f(this.uCamPos, camX, camY);
3477
+ gl.uniform1f(this.uZoom, zoom);
3478
+ gl.uniform2f(this.uCanvasSize, W, H);
3479
+ gl.uniform2f(this.uShake, shakeX, shakeY);
3480
+ gl.uniform1i(this.uTexture, 0);
3481
+ gl.activeTexture(gl.TEXTURE0);
3482
+ const renderables = world.query("Transform", "Sprite");
3483
+ renderables.sort((a, b) => {
3484
+ const sa = world.getComponent(a, "Sprite");
3485
+ const sb = world.getComponent(b, "Sprite");
3486
+ const zd = sa.zIndex - sb.zIndex;
3487
+ if (zd !== 0) return zd;
3488
+ const ka = getTextureKey(sa), kb = getTextureKey(sb);
3489
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
3490
+ });
3491
+ let batchCount = 0;
3492
+ let batchKey = "";
3493
+ for (let i = 0; i <= renderables.length; i++) {
3494
+ if (i === renderables.length) {
3495
+ this.flush(batchCount, batchKey);
3496
+ break;
3497
+ }
3498
+ const id = renderables[i];
3499
+ const transform = world.getComponent(id, "Transform");
3500
+ const sprite = world.getComponent(id, "Sprite");
3501
+ if (!sprite.visible) continue;
3502
+ if (sprite.src && !sprite.image) {
3503
+ let img = this.imageCache.get(sprite.src);
3504
+ if (!img) {
3505
+ img = new Image();
3506
+ img.src = sprite.src;
3507
+ this.imageCache.set(sprite.src, img);
3508
+ img.onload = () => {
3509
+ const tex = this.gl.createTexture();
3510
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
3511
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
3512
+ this.gl.generateMipmap(this.gl.TEXTURE_2D);
3513
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
3514
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
3515
+ this.textures.set(sprite.src, tex);
3516
+ };
3517
+ }
3518
+ sprite.image = img;
3519
+ }
3520
+ const key = getTextureKey(sprite);
3521
+ if (key !== batchKey && batchCount > 0 || batchCount >= MAX_INSTANCES) {
3522
+ this.flush(batchCount, batchKey);
3523
+ batchCount = 0;
3524
+ }
3525
+ batchKey = key;
3526
+ const ss = world.getComponent(id, "SquashStretch");
3527
+ const scaleXMod = ss ? ss.currentScaleX : 1;
3528
+ const scaleYMod = ss ? ss.currentScaleY : 1;
3529
+ const [r, g, b, a] = parseCSSColor(sprite.color);
3530
+ const uv = getUVRect(sprite);
3531
+ this.writeInstance(
3532
+ batchCount * FLOATS_PER_INSTANCE,
3533
+ transform.x,
3534
+ transform.y,
3535
+ sprite.width * transform.scaleX * scaleXMod,
3536
+ sprite.height * transform.scaleY * scaleYMod,
3537
+ transform.rotation,
3538
+ sprite.anchorX,
3539
+ sprite.anchorY,
3540
+ sprite.offsetX,
3541
+ sprite.offsetY,
3542
+ sprite.flipX,
3543
+ r,
3544
+ g,
3545
+ b,
3546
+ a,
3547
+ uv[0],
3548
+ uv[1],
3549
+ uv[2],
3550
+ uv[3]
3551
+ );
3552
+ batchCount++;
3553
+ }
3554
+ const textEntities = world.query("Transform", "Text");
3555
+ textEntities.sort((a, b) => {
3556
+ const ta = world.getComponent(a, "Text");
3557
+ const tb = world.getComponent(b, "Text");
3558
+ return ta.zIndex - tb.zIndex;
3559
+ });
3560
+ for (const id of textEntities) {
3561
+ const transform = world.getComponent(id, "Transform");
3562
+ const text = world.getComponent(id, "Text");
3563
+ if (!text.visible) continue;
3564
+ const entry = this.getOrCreateTextTexture(text);
3565
+ if (!entry) continue;
3566
+ this.flush(batchCount, batchKey);
3567
+ batchCount = 0;
3568
+ batchKey = "";
3569
+ this.writeInstance(
3570
+ 0,
3571
+ transform.x + text.offsetX,
3572
+ transform.y + text.offsetY,
3573
+ entry.w,
3574
+ entry.h,
3575
+ transform.rotation,
3576
+ 0,
3577
+ 0,
3578
+ // anchor top-left
3579
+ 0,
3580
+ 0,
3581
+ false,
3582
+ 1,
3583
+ 1,
3584
+ 1,
3585
+ 1,
3586
+ // white tint — color baked into texture
3587
+ 0,
3588
+ 0,
3589
+ 1,
3590
+ 1
3591
+ );
3592
+ this.flushWithTex(1, entry.tex, true);
3593
+ }
3594
+ for (const id of world.query("Transform", "ParticlePool")) {
3595
+ const t = world.getComponent(id, "Transform");
3596
+ const pool = world.getComponent(id, "ParticlePool");
3597
+ pool.particles = pool.particles.filter((p) => {
3598
+ p.life -= dt;
3599
+ p.x += p.vx * dt;
3600
+ p.y += p.vy * dt;
3601
+ p.vy += p.gravity * dt;
3602
+ return p.life > 0;
3603
+ });
3604
+ if (pool.active && pool.particles.length < pool.maxParticles) {
3605
+ pool.timer += dt;
3606
+ const spawnCount = Math.floor(pool.timer * pool.rate);
3607
+ pool.timer -= spawnCount / pool.rate;
3608
+ for (let i = 0; i < spawnCount && pool.particles.length < pool.maxParticles; i++) {
3609
+ const angle = pool.angle + (world.rng() - 0.5) * pool.spread;
3610
+ const speed = pool.speed * (0.5 + world.rng() * 0.5);
3611
+ pool.particles.push({
3612
+ x: t.x,
3613
+ y: t.y,
3614
+ vx: Math.cos(angle) * speed,
3615
+ vy: Math.sin(angle) * speed,
3616
+ life: pool.particleLife,
3617
+ maxLife: pool.particleLife,
3618
+ size: pool.particleSize,
3619
+ color: pool.color,
3620
+ gravity: pool.gravity
3621
+ });
3622
+ }
3623
+ }
3624
+ let pCount = 0;
3625
+ const pKey = `__color__`;
3626
+ for (const p of pool.particles) {
3627
+ if (pCount >= MAX_INSTANCES) {
3628
+ this.flush(pCount, pKey);
3629
+ pCount = 0;
3630
+ }
3631
+ const alpha = p.life / p.maxLife;
3632
+ const [r, g, b] = parseCSSColor(p.color);
3633
+ this.writeInstance(
3634
+ pCount * FLOATS_PER_INSTANCE,
3635
+ p.x,
3636
+ p.y,
3637
+ p.size,
3638
+ p.size,
3639
+ 0,
3640
+ 0.5,
3641
+ 0.5,
3642
+ 0,
3643
+ 0,
3644
+ false,
3645
+ r,
3646
+ g,
3647
+ b,
3648
+ alpha,
3649
+ 0,
3650
+ 0,
3651
+ 1,
3652
+ 1
3653
+ );
3654
+ pCount++;
3655
+ }
3656
+ if (pCount > 0) this.flush(pCount, pKey);
3657
+ }
3658
+ for (const id of world.query("Transform", "Trail")) {
3659
+ const t = world.getComponent(id, "Transform");
3660
+ const trail = world.getComponent(id, "Trail");
3661
+ trail.points.unshift({ x: t.x, y: t.y });
3662
+ if (trail.points.length > trail.length) trail.points.length = trail.length;
3663
+ if (trail.points.length < 1) continue;
3664
+ const [tr, tg, tb] = parseCSSColor(trail.color);
3665
+ const trailW = trail.width > 0 ? trail.width : 1;
3666
+ let tCount = 0;
3667
+ for (let i = 0; i < trail.points.length; i++) {
3668
+ if (tCount >= MAX_INSTANCES) {
3669
+ this.flush(tCount, "__color__");
3670
+ tCount = 0;
3671
+ }
3672
+ const alpha = 1 - i / trail.points.length;
3673
+ this.writeInstance(
3674
+ tCount * FLOATS_PER_INSTANCE,
3675
+ trail.points[i].x,
3676
+ trail.points[i].y,
3677
+ trailW,
3678
+ trailW,
3679
+ 0,
3680
+ 0.5,
3681
+ 0.5,
3682
+ 0,
3683
+ 0,
3684
+ false,
3685
+ tr,
3686
+ tg,
3687
+ tb,
3688
+ alpha,
3689
+ 0,
3690
+ 0,
3691
+ 1,
3692
+ 1
3693
+ );
3694
+ tCount++;
3695
+ }
3696
+ if (tCount > 0) this.flush(tCount, "__color__");
3697
+ }
3698
+ }
3699
+ };
3700
+
2890
3701
  // src/components/Game.tsx
2891
3702
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2892
3703
  function timedSystem(name, system, timings) {
@@ -2916,6 +3727,7 @@ function Game({
2916
3727
  children
2917
3728
  }) {
2918
3729
  const canvasRef = useRef3(null);
3730
+ const debugCanvasRef = useRef3(null);
2919
3731
  const wrapperRef = useRef3(null);
2920
3732
  const [engine, setEngine] = useState2(null);
2921
3733
  const [assetsReady, setAssetsReady] = useState2(asyncAssets);
@@ -2932,14 +3744,34 @@ function Game({
2932
3744
  let canvas2d;
2933
3745
  let builtinRenderSystem;
2934
3746
  let renderSystem;
2935
- if (CustomRenderer) {
2936
- renderSystem = new CustomRenderer(canvas, entityIds);
2937
- } else {
3747
+ let activeRenderSystem;
3748
+ if (CustomRenderer === "canvas2d") {
2938
3749
  canvas2d = new Canvas2DRenderer(canvas);
2939
3750
  builtinRenderSystem = new RenderSystem(canvas2d, entityIds);
2940
3751
  renderSystem = builtinRenderSystem;
3752
+ } else if (CustomRenderer) {
3753
+ renderSystem = new CustomRenderer(canvas, entityIds);
3754
+ } else {
3755
+ try {
3756
+ renderSystem = new WebGLRenderSystem(canvas, entityIds);
3757
+ } catch (e) {
3758
+ console.warn("[Cubeforge] WebGL2 unavailable, falling back to Canvas2D:", e);
3759
+ canvas2d = new Canvas2DRenderer(canvas);
3760
+ builtinRenderSystem = new RenderSystem(canvas2d, entityIds);
3761
+ renderSystem = builtinRenderSystem;
3762
+ }
3763
+ }
3764
+ activeRenderSystem = renderSystem;
3765
+ let debugSystem = null;
3766
+ if (debug) {
3767
+ const debugCanvas2dEl = debugCanvasRef.current;
3768
+ if (debugCanvas2dEl) {
3769
+ const debugCanvas2d = new Canvas2DRenderer(debugCanvas2dEl);
3770
+ debugSystem = new DebugSystem(debugCanvas2d);
3771
+ } else if (canvas2d) {
3772
+ debugSystem = new DebugSystem(canvas2d);
3773
+ }
2941
3774
  }
2942
- const debugSystem = debug && canvas2d ? new DebugSystem(canvas2d) : null;
2943
3775
  const systemTimings = /* @__PURE__ */ new Map();
2944
3776
  ecs.addSystem(timedSystem("ScriptSystem", new ScriptSystem(input), systemTimings));
2945
3777
  ecs.addSystem(timedSystem("PhysicsSystem", physics, systemTimings));
@@ -2960,7 +3792,20 @@ function Game({
2960
3792
  handle.onFrame?.();
2961
3793
  }
2962
3794
  });
2963
- const state = { ecs, input, renderer: canvas2d, renderSystem: builtinRenderSystem, physics, events, assets, loop, canvas, entityIds, systemTimings };
3795
+ const state = {
3796
+ ecs,
3797
+ input,
3798
+ renderer: canvas2d,
3799
+ renderSystem: builtinRenderSystem,
3800
+ activeRenderSystem,
3801
+ physics,
3802
+ events,
3803
+ assets,
3804
+ loop,
3805
+ canvas,
3806
+ entityIds,
3807
+ systemTimings
3808
+ };
2964
3809
  setEngine(state);
2965
3810
  if (plugins) {
2966
3811
  const pluginNames = new Set(plugins.map((p) => p.name));
@@ -2999,6 +3844,11 @@ function Game({
2999
3844
  const s2 = Math.min(scaleX, scaleY);
3000
3845
  canvas.style.transform = `scale(${s2})`;
3001
3846
  canvas.style.transformOrigin = "top left";
3847
+ const debugEl = debugCanvasRef.current;
3848
+ if (debugEl) {
3849
+ debugEl.style.transform = `scale(${s2})`;
3850
+ debugEl.style.transformOrigin = "top left";
3851
+ }
3002
3852
  };
3003
3853
  updateScale();
3004
3854
  resizeObserver = new ResizeObserver(updateScale);
@@ -3060,6 +3910,15 @@ function Game({
3060
3910
  className
3061
3911
  }
3062
3912
  ),
3913
+ debug && /* @__PURE__ */ jsx2(
3914
+ "canvas",
3915
+ {
3916
+ ref: debugCanvasRef,
3917
+ width,
3918
+ height,
3919
+ style: { position: "absolute", top: 0, left: 0, pointerEvents: "none" }
3920
+ }
3921
+ ),
3063
3922
  !assetsReady && /* @__PURE__ */ jsxs2("div", { style: {
3064
3923
  position: "absolute",
3065
3924
  inset: 0,
@@ -4313,7 +5172,13 @@ function useCamera() {
4313
5172
  const engine = useGame();
4314
5173
  return useMemo(() => ({
4315
5174
  shake(intensity, duration) {
4316
- engine.renderSystem?.triggerShake(intensity, duration);
5175
+ const cams = engine.ecs.query("Camera2D");
5176
+ if (cams.length === 0) return;
5177
+ const cam = engine.ecs.getComponent(cams[0], "Camera2D");
5178
+ if (!cam) return;
5179
+ cam.shakeIntensity = intensity;
5180
+ cam.shakeDuration = duration;
5181
+ cam.shakeTimer = duration;
4317
5182
  },
4318
5183
  setFollowOffset(x, y) {
4319
5184
  const camId = engine.ecs.queryOne("Camera2D");
@@ -5224,6 +6089,7 @@ export {
5224
6089
  BoxCollider,
5225
6090
  Camera2D,
5226
6091
  CameraZone,
6092
+ RenderSystem as Canvas2DRenderSystem,
5227
6093
  CapsuleCollider,
5228
6094
  Checkpoint,
5229
6095
  CircleCollider,
@@ -5243,6 +6109,7 @@ export {
5243
6109
  Trail,
5244
6110
  Transform,
5245
6111
  VirtualJoystick,
6112
+ WebGLRenderSystem,
5246
6113
  World,
5247
6114
  arrive,
5248
6115
  createAtlas,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeforge",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "React-first 2D browser game engine",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {