cubeforge 0.2.2 → 0.3.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/README.md CHANGED
@@ -33,7 +33,7 @@ npm install cubeforge react react-dom
33
33
 
34
34
  - **ECS** — archetype-based entity-component-system with query caching
35
35
  - **Physics** — two-pass AABB, capsule colliders, kinematic bodies, one-way platforms, 60 Hz fixed timestep
36
- - **Renderer** — Canvas2D sprites, animations, trails, parallax, camera with smoothing and bounds
36
+ - **Renderer** — WebGL2 instanced renderer by default; Canvas2D opt-in via `renderer={Canvas2DRenderSystem}`
37
37
  - **Input** — keyboard, mouse, gamepad, per-player input maps, input contexts, recording/playback
38
38
  - **Audio** — Web Audio API with volume groups, fade, crossfade, ducking (`useSound`)
39
39
  - **Gameplay hooks** — `usePlatformerController`, `useTopDownMovement`, `useHealth`, `useSave`, `useGameStateMachine`, `useLevelTransition`, `usePathfinding`, `useAISteering`, and more
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
3
  import React__default, { CSSProperties, ReactNode } from 'react';
4
- import { Plugin, EntityId, System, ECSWorld, ScriptUpdateFn, NavGrid, WorldSnapshot, EventBus } from '@cubeforge/core';
4
+ import { Plugin, EntityId, ECSWorld, ScriptUpdateFn, NavGrid, WorldSnapshot, EventBus } from '@cubeforge/core';
5
5
  export { AssetProgress, Component, ECSWorld, Ease, EntityId, GameTimer, NavGrid, Plugin, PreloadManifest, ScriptUpdateFn, TransformComponent, TweenHandle, Vec2Like, WorldSnapshot, arrive, createTag, createTimer, createTransform, definePlugin, findByTag, flee, patrol, preloadManifest, seek, tween, wander } from '@cubeforge/core';
6
6
  import { InputManager, ActionBindings, InputContextName, PlayerInput, InputRecorderControls } from '@cubeforge/input';
7
7
  export { ActionBindings, AxisBinding, InputContextName, InputManager, InputMap, InputRecorderControls, InputRecording, InputRecording as InputRecordingData, PlayerInput, createInputMap, createInputRecorder, createPlayerInput, globalInputContext } from '@cubeforge/input';
@@ -10,8 +10,8 @@ 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, ParallaxLayerComponent, Particle, ParticlePoolComponent, RenderSystem, SpriteComponent, SquashStretchComponent, TextComponent, TrailComponent, createSprite } from '@cubeforge/renderer';
13
14
  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
15
 
16
16
  interface GameControls {
17
17
  pause(): void;
@@ -49,19 +49,11 @@ interface GameProps {
49
49
  asyncAssets?: boolean;
50
50
  /** Custom plugins to register after core systems. Each plugin's systems run after Render. */
51
51
  plugins?: Plugin[];
52
- /**
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'`
58
- */
59
- renderer?: new (canvas: HTMLCanvasElement, entityIds: Map<string, EntityId>) => System;
60
52
  style?: CSSProperties;
61
53
  className?: string;
62
54
  children?: React__default.ReactNode;
63
55
  }
64
- declare function Game({ width, height, gravity, debug, devtools, scale, deterministic, seed, asyncAssets, onReady, plugins, renderer: CustomRenderer, style, className, children, }: GameProps): react_jsx_runtime.JSX.Element;
56
+ declare function Game({ width, height, gravity, debug, devtools, scale, deterministic, seed, asyncAssets, onReady, plugins, style, className, children, }: GameProps): react_jsx_runtime.JSX.Element;
65
57
 
66
58
  interface WorldProps {
67
59
  /** Gravitational acceleration in pixels/s² (default inherited from Game) */
package/dist/index.js CHANGED
@@ -1115,31 +1115,6 @@ function createInputRecorder() {
1115
1115
  };
1116
1116
  }
1117
1117
 
1118
- // ../../packages/renderer/src/canvas2d.ts
1119
- var Canvas2DRenderer = class {
1120
- constructor(canvas) {
1121
- this.canvas = canvas;
1122
- const ctx = canvas.getContext("2d");
1123
- if (!ctx) throw new Error("Could not get 2D context from canvas");
1124
- this.ctx = ctx;
1125
- }
1126
- ctx;
1127
- clear(color) {
1128
- if (color) {
1129
- this.ctx.fillStyle = color;
1130
- this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
1131
- } else {
1132
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1133
- }
1134
- }
1135
- get width() {
1136
- return this.canvas.width;
1137
- }
1138
- get height() {
1139
- return this.canvas.height;
1140
- }
1141
- };
1142
-
1143
1118
  // ../../packages/renderer/src/components/sprite.ts
1144
1119
  function createSprite(opts) {
1145
1120
  return {
@@ -1187,88 +1162,512 @@ function createTrail(opts) {
1187
1162
  };
1188
1163
  }
1189
1164
 
1190
- // ../../packages/renderer/src/renderSystem.ts
1191
- var imageCache = /* @__PURE__ */ new Map();
1165
+ // ../../packages/renderer/src/shaders.ts
1166
+ var VERT_SRC = `#version 300 es
1167
+ layout(location = 0) in vec2 a_quadPos;
1168
+ layout(location = 1) in vec2 a_uv;
1169
+
1170
+ layout(location = 2) in vec2 i_pos;
1171
+ layout(location = 3) in vec2 i_size;
1172
+ layout(location = 4) in float i_rot;
1173
+ layout(location = 5) in vec2 i_anchor;
1174
+ layout(location = 6) in vec2 i_offset;
1175
+ layout(location = 7) in float i_flipX;
1176
+ layout(location = 8) in vec4 i_color;
1177
+ layout(location = 9) in vec4 i_uvRect;
1178
+
1179
+ uniform vec2 u_camPos;
1180
+ uniform float u_zoom;
1181
+ uniform vec2 u_canvasSize;
1182
+ uniform vec2 u_shake;
1183
+
1184
+ out vec2 v_uv;
1185
+ out vec4 v_color;
1186
+
1187
+ void main() {
1188
+ // Local position: map quad corner (-0.5..0.5) to draw rect, applying anchor
1189
+ vec2 local = (a_quadPos - vec2(i_anchor.x - 0.5, i_anchor.y - 0.5)) * i_size + i_offset;
1190
+
1191
+ // Horizontal flip
1192
+ if (i_flipX > 0.5) local.x = -local.x;
1193
+
1194
+ // Rotate around local origin
1195
+ float c = cos(i_rot);
1196
+ float s = sin(i_rot);
1197
+ local = vec2(c * local.x - s * local.y, s * local.x + c * local.y);
1198
+
1199
+ // World position
1200
+ vec2 world = i_pos + local;
1201
+
1202
+ // Camera \u2192 NDC clip space (Y is flipped: canvas Y down, WebGL Y up)
1203
+ // Equivalent to Canvas2D: translate(W/2 - camX*zoom + shakeX, H/2 - camY*zoom + shakeY); scale(zoom,zoom)
1204
+ float cx = 2.0 * u_zoom / u_canvasSize.x * (world.x - u_camPos.x) + 2.0 * u_shake.x / u_canvasSize.x;
1205
+ float cy = -2.0 * u_zoom / u_canvasSize.y * (world.y - u_camPos.y) - 2.0 * u_shake.y / u_canvasSize.y;
1206
+
1207
+ gl_Position = vec4(cx, cy, 0.0, 1.0);
1208
+
1209
+ // Remap UV [0,1] to the sub-rect defined by i_uvRect
1210
+ v_uv = i_uvRect.xy + a_uv * i_uvRect.zw;
1211
+ v_color = i_color;
1212
+ }
1213
+ `;
1214
+ var FRAG_SRC = `#version 300 es
1215
+ precision mediump float;
1216
+
1217
+ in vec2 v_uv;
1218
+ in vec4 v_color;
1219
+
1220
+ uniform sampler2D u_texture;
1221
+ uniform int u_useTexture;
1222
+
1223
+ out vec4 fragColor;
1224
+
1225
+ void main() {
1226
+ if (u_useTexture == 1) {
1227
+ fragColor = texture(u_texture, v_uv) * v_color;
1228
+ } else {
1229
+ fragColor = v_color;
1230
+ }
1231
+ }
1232
+ `;
1233
+ var PARALLAX_VERT_SRC = `#version 300 es
1234
+ layout(location = 0) in vec2 a_pos;
1235
+
1236
+ out vec2 v_fragCoord;
1237
+
1238
+ void main() {
1239
+ gl_Position = vec4(a_pos, 0.0, 1.0);
1240
+ // Convert NDC (-1..1) to canvas pixel coords (0..canvasSize) in the frag shader
1241
+ v_fragCoord = a_pos * 0.5 + 0.5; // 0..1 normalized screen coord
1242
+ }
1243
+ `;
1244
+ var PARALLAX_FRAG_SRC = `#version 300 es
1245
+ precision mediump float;
1246
+
1247
+ in vec2 v_fragCoord;
1248
+
1249
+ uniform sampler2D u_texture;
1250
+ uniform vec2 u_uvOffset;
1251
+ uniform vec2 u_texSize; // texture size in pixels
1252
+ uniform vec2 u_canvasSize; // canvas size in pixels
1253
+
1254
+ out vec4 fragColor;
1255
+
1256
+ void main() {
1257
+ // Screen pixel position
1258
+ vec2 screenPx = v_fragCoord * u_canvasSize;
1259
+ // Tile: offset by uvOffset and wrap
1260
+ vec2 uv = mod((screenPx / u_texSize + u_uvOffset), 1.0);
1261
+ // Y must be flipped because WebGL origin is bottom-left but canvas is top-left
1262
+ uv.y = 1.0 - uv.y;
1263
+ fragColor = texture(u_texture, uv);
1264
+ }
1265
+ `;
1266
+
1267
+ // ../../packages/renderer/src/colorParser.ts
1268
+ var cache = /* @__PURE__ */ new Map();
1269
+ function parseCSSColor(css) {
1270
+ const hit = cache.get(css);
1271
+ if (hit) return hit;
1272
+ let result = [1, 1, 1, 1];
1273
+ if (css.startsWith("#")) {
1274
+ const h = css.slice(1);
1275
+ if (h.length === 3 || h.length === 4) {
1276
+ const r = parseInt(h[0] + h[0], 16) / 255;
1277
+ const g = parseInt(h[1] + h[1], 16) / 255;
1278
+ const b = parseInt(h[2] + h[2], 16) / 255;
1279
+ const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
1280
+ result = [r, g, b, a];
1281
+ } else if (h.length === 6 || h.length === 8) {
1282
+ const r = parseInt(h.slice(0, 2), 16) / 255;
1283
+ const g = parseInt(h.slice(2, 4), 16) / 255;
1284
+ const b = parseInt(h.slice(4, 6), 16) / 255;
1285
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
1286
+ result = [r, g, b, a];
1287
+ }
1288
+ } else {
1289
+ const m = css.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
1290
+ if (m) {
1291
+ result = [
1292
+ parseInt(m[1]) / 255,
1293
+ parseInt(m[2]) / 255,
1294
+ parseInt(m[3]) / 255,
1295
+ m[4] !== void 0 ? parseFloat(m[4]) : 1
1296
+ ];
1297
+ }
1298
+ }
1299
+ cache.set(css, result);
1300
+ return result;
1301
+ }
1302
+
1303
+ // ../../packages/renderer/src/webglRenderSystem.ts
1304
+ var FLOATS_PER_INSTANCE = 18;
1305
+ var MAX_INSTANCES = 8192;
1306
+ var MAX_TEXT_CACHE = 200;
1307
+ function compileShader(gl, type, src) {
1308
+ const shader = gl.createShader(type);
1309
+ gl.shaderSource(shader, src);
1310
+ gl.compileShader(shader);
1311
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1312
+ throw new Error(`[WebGLRenderer] Shader compile error:
1313
+ ${gl.getShaderInfoLog(shader)}`);
1314
+ }
1315
+ return shader;
1316
+ }
1317
+ function createProgram(gl, vertSrc, fragSrc) {
1318
+ const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
1319
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
1320
+ const prog = gl.createProgram();
1321
+ gl.attachShader(prog, vert);
1322
+ gl.attachShader(prog, frag);
1323
+ gl.linkProgram(prog);
1324
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
1325
+ throw new Error(`[WebGLRenderer] Program link error:
1326
+ ${gl.getProgramInfoLog(prog)}`);
1327
+ }
1328
+ gl.deleteShader(vert);
1329
+ gl.deleteShader(frag);
1330
+ return prog;
1331
+ }
1332
+ function createWhiteTexture(gl) {
1333
+ const tex = gl.createTexture();
1334
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1335
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]));
1336
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1337
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1338
+ return tex;
1339
+ }
1340
+ function getTextureKey(sprite) {
1341
+ if (sprite.image?.src) return sprite.image.src;
1342
+ if (sprite.src) return sprite.src;
1343
+ return `__color__:${sprite.color}`;
1344
+ }
1345
+ function getUVRect(sprite) {
1346
+ if (!sprite.image || sprite.image.naturalWidth === 0) return [0, 0, 1, 1];
1347
+ const iw = sprite.image.naturalWidth;
1348
+ const ih = sprite.image.naturalHeight;
1349
+ if (sprite.frameWidth && sprite.frameHeight) {
1350
+ const cols = sprite.frameColumns ?? Math.floor(iw / sprite.frameWidth);
1351
+ const col = sprite.frameIndex % cols;
1352
+ const row = Math.floor(sprite.frameIndex / cols);
1353
+ return [
1354
+ col * sprite.frameWidth / iw,
1355
+ row * sprite.frameHeight / ih,
1356
+ sprite.frameWidth / iw,
1357
+ sprite.frameHeight / ih
1358
+ ];
1359
+ }
1360
+ if (sprite.frame) {
1361
+ const { sx, sy, sw, sh } = sprite.frame;
1362
+ return [sx / iw, sy / ih, sw / iw, sh / ih];
1363
+ }
1364
+ return [0, 0, 1, 1];
1365
+ }
1192
1366
  var RenderSystem = class {
1193
- constructor(renderer, entityIds) {
1194
- this.renderer = renderer;
1367
+ constructor(canvas, entityIds) {
1368
+ this.canvas = canvas;
1195
1369
  this.entityIds = entityIds;
1370
+ const gl = canvas.getContext("webgl2", { alpha: false, antialias: false, premultipliedAlpha: false });
1371
+ if (!gl) throw new Error("[WebGLRenderer] WebGL2 is not supported in this browser");
1372
+ this.gl = gl;
1373
+ this.program = createProgram(gl, VERT_SRC, FRAG_SRC);
1374
+ const quadVerts = new Float32Array([
1375
+ -0.5,
1376
+ -0.5,
1377
+ 0,
1378
+ 0,
1379
+ 0.5,
1380
+ -0.5,
1381
+ 1,
1382
+ 0,
1383
+ -0.5,
1384
+ 0.5,
1385
+ 0,
1386
+ 1,
1387
+ 0.5,
1388
+ -0.5,
1389
+ 1,
1390
+ 0,
1391
+ 0.5,
1392
+ 0.5,
1393
+ 1,
1394
+ 1,
1395
+ -0.5,
1396
+ 0.5,
1397
+ 0,
1398
+ 1
1399
+ ]);
1400
+ this.quadVAO = gl.createVertexArray();
1401
+ gl.bindVertexArray(this.quadVAO);
1402
+ const quadBuf = gl.createBuffer();
1403
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
1404
+ gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW);
1405
+ const qStride = 4 * 4;
1406
+ gl.enableVertexAttribArray(0);
1407
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, qStride, 0);
1408
+ gl.enableVertexAttribArray(1);
1409
+ gl.vertexAttribPointer(1, 2, gl.FLOAT, false, qStride, 2 * 4);
1410
+ this.instanceData = new Float32Array(MAX_INSTANCES * FLOATS_PER_INSTANCE);
1411
+ this.instanceBuffer = gl.createBuffer();
1412
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
1413
+ gl.bufferData(gl.ARRAY_BUFFER, this.instanceData.byteLength, gl.DYNAMIC_DRAW);
1414
+ const iStride = FLOATS_PER_INSTANCE * 4;
1415
+ let byteOffset = 0;
1416
+ const addAttr = (loc, size) => {
1417
+ gl.enableVertexAttribArray(loc);
1418
+ gl.vertexAttribPointer(loc, size, gl.FLOAT, false, iStride, byteOffset);
1419
+ gl.vertexAttribDivisor(loc, 1);
1420
+ byteOffset += size * 4;
1421
+ };
1422
+ addAttr(2, 2);
1423
+ addAttr(3, 2);
1424
+ addAttr(4, 1);
1425
+ addAttr(5, 2);
1426
+ addAttr(6, 2);
1427
+ addAttr(7, 1);
1428
+ addAttr(8, 4);
1429
+ addAttr(9, 4);
1430
+ gl.bindVertexArray(null);
1431
+ gl.useProgram(this.program);
1432
+ this.uCamPos = gl.getUniformLocation(this.program, "u_camPos");
1433
+ this.uZoom = gl.getUniformLocation(this.program, "u_zoom");
1434
+ this.uCanvasSize = gl.getUniformLocation(this.program, "u_canvasSize");
1435
+ this.uShake = gl.getUniformLocation(this.program, "u_shake");
1436
+ this.uTexture = gl.getUniformLocation(this.program, "u_texture");
1437
+ this.uUseTexture = gl.getUniformLocation(this.program, "u_useTexture");
1438
+ this.whiteTexture = createWhiteTexture(gl);
1439
+ this.parallaxProgram = createProgram(gl, PARALLAX_VERT_SRC, PARALLAX_FRAG_SRC);
1440
+ const fsVerts = new Float32Array([
1441
+ -1,
1442
+ -1,
1443
+ 1,
1444
+ -1,
1445
+ -1,
1446
+ 1,
1447
+ 1,
1448
+ -1,
1449
+ 1,
1450
+ 1,
1451
+ -1,
1452
+ 1
1453
+ ]);
1454
+ this.parallaxVAO = gl.createVertexArray();
1455
+ gl.bindVertexArray(this.parallaxVAO);
1456
+ const fsBuf = gl.createBuffer();
1457
+ gl.bindBuffer(gl.ARRAY_BUFFER, fsBuf);
1458
+ gl.bufferData(gl.ARRAY_BUFFER, fsVerts, gl.STATIC_DRAW);
1459
+ gl.enableVertexAttribArray(0);
1460
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0);
1461
+ gl.bindVertexArray(null);
1462
+ gl.useProgram(this.parallaxProgram);
1463
+ this.pUTexture = gl.getUniformLocation(this.parallaxProgram, "u_texture");
1464
+ this.pUUvOffset = gl.getUniformLocation(this.parallaxProgram, "u_uvOffset");
1465
+ this.pUTexSize = gl.getUniformLocation(this.parallaxProgram, "u_texSize");
1466
+ this.pUCanvasSize = gl.getUniformLocation(this.parallaxProgram, "u_canvasSize");
1467
+ gl.enable(gl.BLEND);
1468
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1469
+ }
1470
+ gl;
1471
+ program;
1472
+ quadVAO;
1473
+ instanceBuffer;
1474
+ instanceData;
1475
+ whiteTexture;
1476
+ textures = /* @__PURE__ */ new Map();
1477
+ imageCache = /* @__PURE__ */ new Map();
1478
+ // Cached uniform locations — sprite program
1479
+ uCamPos;
1480
+ uZoom;
1481
+ uCanvasSize;
1482
+ uShake;
1483
+ uTexture;
1484
+ uUseTexture;
1485
+ // ── Parallax program ──────────────────────────────────────────────────────
1486
+ parallaxProgram;
1487
+ parallaxVAO;
1488
+ parallaxTextures = /* @__PURE__ */ new Map();
1489
+ parallaxImageCache = /* @__PURE__ */ new Map();
1490
+ // Cached uniform locations — parallax program
1491
+ pUTexture;
1492
+ pUUvOffset;
1493
+ pUTexSize;
1494
+ pUCanvasSize;
1495
+ // ── Text texture cache ────────────────────────────────────────────────────
1496
+ textureCache = /* @__PURE__ */ new Map();
1497
+ /** Insertion-order key list for LRU-style eviction. */
1498
+ textureCacheKeys = [];
1499
+ // ── Texture management (sprite textures — CLAMP_TO_EDGE) ──────────────────
1500
+ loadTexture(src) {
1501
+ const cached = this.textures.get(src);
1502
+ if (cached) return cached;
1503
+ let img = this.imageCache.get(src);
1504
+ if (!img) {
1505
+ img = new Image();
1506
+ img.src = src;
1507
+ img.onload = () => {
1508
+ const tex = this.gl.createTexture();
1509
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
1510
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
1511
+ this.gl.generateMipmap(this.gl.TEXTURE_2D);
1512
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
1513
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
1514
+ this.textures.set(src, tex);
1515
+ };
1516
+ this.imageCache.set(src, img);
1517
+ }
1518
+ return this.whiteTexture;
1196
1519
  }
1197
- debug = false;
1198
- pendingShake = null;
1199
- // FPS tracking
1200
- frameTimes = [];
1201
- lastTimestamp = 0;
1202
- // Debug overlays
1203
- debugNavGrid = null;
1204
- contactFlashPoints = [];
1205
- setDebug(v) {
1206
- this.debug = v;
1520
+ // ── Parallax texture management (REPEAT wrap mode) ────────────────────────
1521
+ loadParallaxTexture(src) {
1522
+ const cached = this.parallaxTextures.get(src);
1523
+ if (cached) return cached;
1524
+ let img = this.parallaxImageCache.get(src);
1525
+ if (!img) {
1526
+ img = new Image();
1527
+ img.src = src;
1528
+ img.onload = () => {
1529
+ const gl = this.gl;
1530
+ const tex = gl.createTexture();
1531
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1532
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
1533
+ gl.generateMipmap(gl.TEXTURE_2D);
1534
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
1535
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1536
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
1537
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
1538
+ this.parallaxTextures.set(src, tex);
1539
+ };
1540
+ this.parallaxImageCache.set(src, img);
1541
+ }
1542
+ return null;
1543
+ }
1544
+ // ── Text texture management ───────────────────────────────────────────────
1545
+ getTextTextureKey(text) {
1546
+ return `${text.text}|${text.fontSize ?? 16}|${text.fontFamily ?? "monospace"}|${text.color ?? "#ffffff"}`;
1547
+ }
1548
+ getOrCreateTextTexture(text) {
1549
+ const key = this.getTextTextureKey(text);
1550
+ const cached = this.textureCache.get(key);
1551
+ if (cached) return cached;
1552
+ if (this.textureCache.size >= MAX_TEXT_CACHE) {
1553
+ const oldest = this.textureCacheKeys.shift();
1554
+ if (oldest) {
1555
+ const old = this.textureCache.get(oldest);
1556
+ if (old) this.gl.deleteTexture(old.tex);
1557
+ this.textureCache.delete(oldest);
1558
+ }
1559
+ }
1560
+ const offscreen = document.createElement("canvas");
1561
+ const ctx2d = offscreen.getContext("2d");
1562
+ const font = `${text.fontSize ?? 16}px ${text.fontFamily ?? "monospace"}`;
1563
+ ctx2d.font = font;
1564
+ const metrics = ctx2d.measureText(text.text);
1565
+ const textW = Math.ceil(metrics.width) + 4;
1566
+ const textH = Math.ceil((text.fontSize ?? 16) * 1.5) + 4;
1567
+ offscreen.width = textW;
1568
+ offscreen.height = textH;
1569
+ ctx2d.font = font;
1570
+ ctx2d.fillStyle = text.color ?? "#ffffff";
1571
+ ctx2d.textAlign = "left";
1572
+ ctx2d.textBaseline = "top";
1573
+ ctx2d.fillText(text.text, 2, 2, text.maxWidth);
1574
+ const gl = this.gl;
1575
+ const tex = gl.createTexture();
1576
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1577
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, offscreen);
1578
+ gl.generateMipmap(gl.TEXTURE_2D);
1579
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
1580
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1581
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1582
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1583
+ const entry = { tex, w: textW, h: textH };
1584
+ this.textureCache.set(key, entry);
1585
+ this.textureCacheKeys.push(key);
1586
+ return entry;
1207
1587
  }
1208
- /** Overlay a nav grid: green = walkable, red = blocked. Pass null to clear. */
1209
- setDebugNavGrid(grid) {
1210
- this.debugNavGrid = grid;
1588
+ // ── Instanced draw call ────────────────────────────────────────────────────
1589
+ flush(count, textureKey) {
1590
+ if (count === 0) return;
1591
+ const { gl } = this;
1592
+ const isColor = textureKey.startsWith("__color__");
1593
+ const tex = isColor ? this.whiteTexture : this.loadTexture(textureKey);
1594
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1595
+ gl.uniform1i(this.uUseTexture, isColor ? 0 : 1);
1596
+ gl.bindVertexArray(this.quadVAO);
1597
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
1598
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
1599
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
1211
1600
  }
1212
- /** Flash a point on the canvas for one frame (world-space coords). */
1213
- flashContactPoint(x, y) {
1214
- this.contactFlashPoints.push({ x, y, ttl: 1 });
1601
+ flushWithTex(count, tex, useTexture) {
1602
+ if (count === 0) return;
1603
+ const { gl } = this;
1604
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1605
+ gl.uniform1i(this.uUseTexture, useTexture ? 1 : 0);
1606
+ gl.bindVertexArray(this.quadVAO);
1607
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
1608
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
1609
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
1215
1610
  }
1216
- triggerShake(intensity, duration) {
1217
- this.pendingShake = { intensity, duration };
1611
+ // ── Write one sprite instance into instanceData ───────────────────────────
1612
+ writeInstance(base, x, y, w, h, rot, anchorX, anchorY, offsetX, offsetY, flipX, r, g, b, a, u, v, uw, vh) {
1613
+ const d = this.instanceData;
1614
+ d[base + 0] = x;
1615
+ d[base + 1] = y;
1616
+ d[base + 2] = w;
1617
+ d[base + 3] = h;
1618
+ d[base + 4] = rot;
1619
+ d[base + 5] = anchorX;
1620
+ d[base + 6] = anchorY;
1621
+ d[base + 7] = offsetX;
1622
+ d[base + 8] = offsetY;
1623
+ d[base + 9] = flipX ? 1 : 0;
1624
+ d[base + 10] = r;
1625
+ d[base + 11] = g;
1626
+ d[base + 12] = b;
1627
+ d[base + 13] = a;
1628
+ d[base + 14] = u;
1629
+ d[base + 15] = v;
1630
+ d[base + 16] = uw;
1631
+ d[base + 17] = vh;
1218
1632
  }
1633
+ // ── Main update loop ───────────────────────────────────────────────────────
1219
1634
  update(world, dt) {
1220
- const { ctx, canvas } = this.renderer;
1221
- const now = performance.now();
1222
- if (this.lastTimestamp > 0) {
1223
- this.frameTimes.push(now - this.lastTimestamp);
1224
- if (this.frameTimes.length > 60) this.frameTimes.shift();
1225
- }
1226
- this.lastTimestamp = now;
1227
- let camX = 0;
1228
- let camY = 0;
1229
- let zoom = 1;
1635
+ const { gl, canvas } = this;
1636
+ const W = canvas.width;
1637
+ const H = canvas.height;
1638
+ let camX = 0, camY = 0, zoom = 1;
1230
1639
  let background = "#000000";
1231
- let shakeX = 0;
1232
- let shakeY = 0;
1233
- const camEntityId = world.queryOne("Camera2D");
1234
- if (camEntityId !== void 0) {
1235
- const cam = world.getComponent(camEntityId, "Camera2D");
1640
+ let shakeX = 0, shakeY = 0;
1641
+ const camId = world.queryOne("Camera2D");
1642
+ if (camId !== void 0) {
1643
+ const cam = world.getComponent(camId, "Camera2D");
1236
1644
  background = cam.background;
1237
- if (this.pendingShake) {
1238
- cam.shakeIntensity = this.pendingShake.intensity;
1239
- cam.shakeDuration = this.pendingShake.duration;
1240
- cam.shakeTimer = this.pendingShake.duration;
1241
- this.pendingShake = null;
1242
- }
1243
1645
  if (cam.followEntityId) {
1244
1646
  const targetId = this.entityIds.get(cam.followEntityId);
1245
1647
  if (targetId !== void 0) {
1246
- const targetTransform = world.getComponent(targetId, "Transform");
1247
- if (targetTransform) {
1248
- const tx = targetTransform.x + (cam.followOffsetX ?? 0);
1249
- const ty = targetTransform.y + (cam.followOffsetY ?? 0);
1648
+ const t = world.getComponent(targetId, "Transform");
1649
+ if (t) {
1250
1650
  if (cam.deadZone) {
1251
1651
  const halfW = cam.deadZone.w / 2;
1252
1652
  const halfH = cam.deadZone.h / 2;
1253
- const dx = tx - cam.x;
1254
- const dy = ty - cam.y;
1255
- if (dx > halfW) cam.x = tx - halfW;
1256
- else if (dx < -halfW) cam.x = tx + halfW;
1257
- if (dy > halfH) cam.y = ty - halfH;
1258
- else if (dy < -halfH) cam.y = ty + halfH;
1653
+ const dx = t.x - cam.x, dy = t.y - cam.y;
1654
+ if (dx > halfW) cam.x = t.x - halfW;
1655
+ else if (dx < -halfW) cam.x = t.x + halfW;
1656
+ if (dy > halfH) cam.y = t.y - halfH;
1657
+ else if (dy < -halfH) cam.y = t.y + halfH;
1259
1658
  } else if (cam.smoothing > 0) {
1260
- cam.x += (tx - cam.x) * (1 - cam.smoothing);
1261
- cam.y += (ty - cam.y) * (1 - cam.smoothing);
1659
+ cam.x += (t.x - cam.x) * (1 - cam.smoothing);
1660
+ cam.y += (t.y - cam.y) * (1 - cam.smoothing);
1262
1661
  } else {
1263
- cam.x = tx;
1264
- cam.y = ty;
1662
+ cam.x = t.x;
1663
+ cam.y = t.y;
1265
1664
  }
1266
1665
  }
1267
1666
  }
1268
1667
  }
1269
1668
  if (cam.bounds) {
1270
- const halfW = canvas.width / (2 * cam.zoom);
1271
- const halfH = canvas.height / (2 * cam.zoom);
1669
+ const halfW = W / (2 * cam.zoom);
1670
+ const halfH = H / (2 * cam.zoom);
1272
1671
  cam.x = Math.max(cam.bounds.x + halfW, Math.min(cam.bounds.x + cam.bounds.width - halfW, cam.x));
1273
1672
  cam.y = Math.max(cam.bounds.y + halfH, Math.min(cam.bounds.y + cam.bounds.height - halfH, cam.y));
1274
1673
  }
@@ -1293,140 +1692,138 @@ var RenderSystem = class {
1293
1692
  anim.timer -= frameDuration;
1294
1693
  anim.currentIndex++;
1295
1694
  if (anim.currentIndex >= anim.frames.length) {
1296
- if (anim.loop) {
1297
- anim.currentIndex = 0;
1298
- } else {
1299
- anim.currentIndex = anim.frames.length - 1;
1300
- anim.playing = false;
1301
- if (anim.onComplete && !anim._completed) {
1302
- anim._completed = true;
1303
- anim.onComplete();
1304
- }
1305
- }
1695
+ anim.currentIndex = anim.loop ? 0 : anim.frames.length - 1;
1306
1696
  }
1307
- anim.frameEvents?.[anim.currentIndex]?.();
1308
1697
  }
1309
1698
  sprite.frameIndex = anim.frames[anim.currentIndex];
1310
1699
  }
1311
1700
  for (const id of world.query("SquashStretch", "RigidBody")) {
1312
1701
  const ss = world.getComponent(id, "SquashStretch");
1313
1702
  const rb = world.getComponent(id, "RigidBody");
1314
- const speed = Math.sqrt(rb.vx * rb.vx + rb.vy * rb.vy);
1315
- const targetScaleX = rb.vy < -100 ? 1 + ss.intensity * 0.4 : speed > 50 ? 1 - ss.intensity * 0.3 : 1;
1316
- const targetScaleY = rb.vy < -100 ? 1 - ss.intensity * 0.4 : speed > 50 ? 1 + ss.intensity * 0.3 : 1;
1317
- ss.currentScaleX += (targetScaleX - ss.currentScaleX) * ss.recovery * dt;
1318
- ss.currentScaleY += (targetScaleY - ss.currentScaleY) * ss.recovery * dt;
1703
+ const spd = Math.sqrt(rb.vx * rb.vx + rb.vy * rb.vy);
1704
+ const tScX = rb.vy < -100 ? 1 + ss.intensity * 0.4 : spd > 50 ? 1 - ss.intensity * 0.3 : 1;
1705
+ const tScY = rb.vy < -100 ? 1 - ss.intensity * 0.4 : spd > 50 ? 1 + ss.intensity * 0.3 : 1;
1706
+ ss.currentScaleX += (tScX - ss.currentScaleX) * ss.recovery * dt;
1707
+ ss.currentScaleY += (tScY - ss.currentScaleY) * ss.recovery * dt;
1319
1708
  }
1320
- this.renderer.clear(background);
1709
+ const [br, bg, bb] = parseCSSColor(background);
1710
+ gl.viewport(0, 0, W, H);
1711
+ gl.clearColor(br, bg, bb, 1);
1712
+ gl.clear(gl.COLOR_BUFFER_BIT);
1321
1713
  const parallaxEntities = world.query("ParallaxLayer");
1322
- parallaxEntities.sort((a, b) => {
1323
- const za = world.getComponent(a, "ParallaxLayer").zIndex;
1324
- const zb = world.getComponent(b, "ParallaxLayer").zIndex;
1325
- return za - zb;
1326
- });
1327
- for (const id of parallaxEntities) {
1328
- const layer = world.getComponent(id, "ParallaxLayer");
1329
- let img = imageCache.get(layer.src);
1330
- if (!img) {
1331
- img = new Image();
1332
- img.src = layer.src;
1333
- img.onload = () => {
1334
- layer.imageWidth = img.naturalWidth;
1335
- layer.imageHeight = img.naturalHeight;
1336
- };
1337
- imageCache.set(layer.src, img);
1338
- }
1339
- if (!img.complete || img.naturalWidth === 0) continue;
1340
- if (layer.imageWidth === 0) layer.imageWidth = img.naturalWidth;
1341
- if (layer.imageHeight === 0) layer.imageHeight = img.naturalHeight;
1342
- const imgW = layer.imageWidth;
1343
- const imgH = layer.imageHeight;
1344
- const drawX = layer.offsetX - camX * layer.speedX;
1345
- const drawY = layer.offsetY - camY * layer.speedY;
1346
- ctx.save();
1347
- if (layer.repeatX || layer.repeatY) {
1348
- const pattern = ctx.createPattern(img, layer.repeatX && layer.repeatY ? "repeat" : layer.repeatX ? "repeat-x" : "repeat-y");
1349
- if (pattern) {
1350
- const offsetX = (drawX % imgW + imgW) % imgW;
1351
- const offsetY = (drawY % imgH + imgH) % imgH;
1352
- const matrix = new DOMMatrix();
1353
- matrix.translateSelf(offsetX, offsetY);
1354
- pattern.setTransform(matrix);
1355
- ctx.fillStyle = pattern;
1356
- ctx.fillRect(0, 0, canvas.width, canvas.height);
1714
+ if (parallaxEntities.length > 0) {
1715
+ parallaxEntities.sort((a, b) => {
1716
+ const za = world.getComponent(a, "ParallaxLayer").zIndex;
1717
+ const zb = world.getComponent(b, "ParallaxLayer").zIndex;
1718
+ return za - zb;
1719
+ });
1720
+ gl.useProgram(this.parallaxProgram);
1721
+ gl.uniform2f(this.pUCanvasSize, W, H);
1722
+ gl.uniform1i(this.pUTexture, 0);
1723
+ gl.activeTexture(gl.TEXTURE0);
1724
+ gl.bindVertexArray(this.parallaxVAO);
1725
+ for (const id of parallaxEntities) {
1726
+ const layer = world.getComponent(id, "ParallaxLayer");
1727
+ let img = this.parallaxImageCache.get(layer.src);
1728
+ if (!img) {
1729
+ this.loadParallaxTexture(layer.src);
1730
+ continue;
1357
1731
  }
1358
- } else {
1359
- ctx.drawImage(img, drawX, drawY, imgW, imgH);
1732
+ if (!img.complete || img.naturalWidth === 0) continue;
1733
+ if (layer.imageWidth === 0) layer.imageWidth = img.naturalWidth;
1734
+ if (layer.imageHeight === 0) layer.imageHeight = img.naturalHeight;
1735
+ const tex = this.parallaxTextures.get(layer.src);
1736
+ if (!tex) continue;
1737
+ const imgW = layer.imageWidth;
1738
+ const imgH = layer.imageHeight;
1739
+ const drawX = layer.offsetX - camX * layer.speedX;
1740
+ const drawY = layer.offsetY - camY * layer.speedY;
1741
+ const uvOffsetX = (drawX / imgW % 1 + 1) % 1;
1742
+ const uvOffsetY = (drawY / imgH % 1 + 1) % 1;
1743
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1744
+ gl.uniform2f(this.pUUvOffset, uvOffsetX, uvOffsetY);
1745
+ gl.uniform2f(this.pUTexSize, imgW, imgH);
1746
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1360
1747
  }
1361
- ctx.restore();
1362
1748
  }
1363
- ctx.save();
1364
- ctx.translate(
1365
- canvas.width / 2 - camX * zoom + shakeX,
1366
- canvas.height / 2 - camY * zoom + shakeY
1367
- );
1368
- ctx.scale(zoom, zoom);
1749
+ gl.useProgram(this.program);
1750
+ gl.uniform2f(this.uCamPos, camX, camY);
1751
+ gl.uniform1f(this.uZoom, zoom);
1752
+ gl.uniform2f(this.uCanvasSize, W, H);
1753
+ gl.uniform2f(this.uShake, shakeX, shakeY);
1754
+ gl.uniform1i(this.uTexture, 0);
1755
+ gl.activeTexture(gl.TEXTURE0);
1369
1756
  const renderables = world.query("Transform", "Sprite");
1370
- const textureKey = (id) => {
1371
- const sprite = world.getComponent(id, "Sprite");
1372
- if (sprite.image && sprite.image.src) return sprite.image.src;
1373
- if (sprite.src) return sprite.src;
1374
- return `__color__:${sprite.color}`;
1375
- };
1376
1757
  renderables.sort((a, b) => {
1377
1758
  const sa = world.getComponent(a, "Sprite");
1378
1759
  const sb = world.getComponent(b, "Sprite");
1379
- const zDiff = sa.zIndex - sb.zIndex;
1380
- if (zDiff !== 0) return zDiff;
1381
- const ka = textureKey(a);
1382
- const kb = textureKey(b);
1383
- if (ka < kb) return -1;
1384
- if (ka > kb) return 1;
1385
- return 0;
1760
+ const zd = sa.zIndex - sb.zIndex;
1761
+ if (zd !== 0) return zd;
1762
+ const ka = getTextureKey(sa), kb = getTextureKey(sb);
1763
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
1386
1764
  });
1387
- for (const id of renderables) {
1765
+ let batchCount = 0;
1766
+ let batchKey = "";
1767
+ for (let i = 0; i <= renderables.length; i++) {
1768
+ if (i === renderables.length) {
1769
+ this.flush(batchCount, batchKey);
1770
+ break;
1771
+ }
1772
+ const id = renderables[i];
1388
1773
  const transform = world.getComponent(id, "Transform");
1389
1774
  const sprite = world.getComponent(id, "Sprite");
1390
1775
  if (!sprite.visible) continue;
1776
+ if (sprite.src && !sprite.image) {
1777
+ let img = this.imageCache.get(sprite.src);
1778
+ if (!img) {
1779
+ img = new Image();
1780
+ img.src = sprite.src;
1781
+ this.imageCache.set(sprite.src, img);
1782
+ img.onload = () => {
1783
+ const tex = this.gl.createTexture();
1784
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
1785
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
1786
+ this.gl.generateMipmap(this.gl.TEXTURE_2D);
1787
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR);
1788
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
1789
+ this.textures.set(sprite.src, tex);
1790
+ };
1791
+ }
1792
+ sprite.image = img;
1793
+ }
1794
+ const key = getTextureKey(sprite);
1795
+ if (key !== batchKey && batchCount > 0 || batchCount >= MAX_INSTANCES) {
1796
+ this.flush(batchCount, batchKey);
1797
+ batchCount = 0;
1798
+ }
1799
+ batchKey = key;
1391
1800
  const ss = world.getComponent(id, "SquashStretch");
1392
1801
  const scaleXMod = ss ? ss.currentScaleX : 1;
1393
1802
  const scaleYMod = ss ? ss.currentScaleY : 1;
1394
- ctx.save();
1395
- ctx.translate(transform.x, transform.y);
1396
- ctx.rotate(transform.rotation);
1397
- ctx.scale(
1398
- transform.scaleX * (sprite.flipX ? -1 : 1) * scaleXMod,
1399
- transform.scaleY * scaleYMod
1803
+ const [r, g, b, a] = parseCSSColor(sprite.color);
1804
+ const uv = getUVRect(sprite);
1805
+ this.writeInstance(
1806
+ batchCount * FLOATS_PER_INSTANCE,
1807
+ transform.x,
1808
+ transform.y,
1809
+ sprite.width * transform.scaleX * scaleXMod,
1810
+ sprite.height * transform.scaleY * scaleYMod,
1811
+ transform.rotation,
1812
+ sprite.anchorX,
1813
+ sprite.anchorY,
1814
+ sprite.offsetX,
1815
+ sprite.offsetY,
1816
+ sprite.flipX,
1817
+ r,
1818
+ g,
1819
+ b,
1820
+ a,
1821
+ uv[0],
1822
+ uv[1],
1823
+ uv[2],
1824
+ uv[3]
1400
1825
  );
1401
- const drawX = -sprite.anchorX * sprite.width + sprite.offsetX;
1402
- const drawY = -sprite.anchorY * sprite.height + sprite.offsetY;
1403
- if (sprite.image && sprite.image.complete && sprite.image.naturalWidth > 0) {
1404
- if (sprite.frameWidth && sprite.frameHeight) {
1405
- const cols = sprite.frameColumns ?? Math.floor(sprite.image.naturalWidth / sprite.frameWidth);
1406
- const col = sprite.frameIndex % cols;
1407
- const row = Math.floor(sprite.frameIndex / cols);
1408
- const sx = col * sprite.frameWidth;
1409
- const sy = row * sprite.frameHeight;
1410
- ctx.drawImage(sprite.image, sx, sy, sprite.frameWidth, sprite.frameHeight, drawX, drawY, sprite.width, sprite.height);
1411
- } else if (sprite.frame) {
1412
- const { sx, sy, sw, sh } = sprite.frame;
1413
- ctx.drawImage(sprite.image, sx, sy, sw, sh, drawX, drawY, sprite.width, sprite.height);
1414
- } else if (sprite.tileX || sprite.tileY) {
1415
- const repeat = sprite.tileX && sprite.tileY ? "repeat" : sprite.tileX ? "repeat-x" : "repeat-y";
1416
- const pat = ctx.createPattern(sprite.image, repeat);
1417
- if (pat) {
1418
- pat.setTransform(new DOMMatrix().translate(drawX, drawY));
1419
- ctx.fillStyle = pat;
1420
- ctx.fillRect(drawX, drawY, sprite.width, sprite.height);
1421
- }
1422
- } else {
1423
- ctx.drawImage(sprite.image, drawX, drawY, sprite.width, sprite.height);
1424
- }
1425
- } else {
1426
- ctx.fillStyle = sprite.color;
1427
- ctx.fillRect(drawX, drawY, sprite.width, sprite.height);
1428
- }
1429
- ctx.restore();
1826
+ batchCount++;
1430
1827
  }
1431
1828
  const textEntities = world.query("Transform", "Text");
1432
1829
  textEntities.sort((a, b) => {
@@ -1438,15 +1835,35 @@ var RenderSystem = class {
1438
1835
  const transform = world.getComponent(id, "Transform");
1439
1836
  const text = world.getComponent(id, "Text");
1440
1837
  if (!text.visible) continue;
1441
- ctx.save();
1442
- ctx.translate(transform.x + text.offsetX, transform.y + text.offsetY);
1443
- ctx.rotate(transform.rotation);
1444
- ctx.font = `${text.fontSize}px ${text.fontFamily}`;
1445
- ctx.fillStyle = text.color;
1446
- ctx.textAlign = text.align;
1447
- ctx.textBaseline = text.baseline;
1448
- ctx.fillText(text.text, 0, 0, text.maxWidth);
1449
- ctx.restore();
1838
+ const entry = this.getOrCreateTextTexture(text);
1839
+ if (!entry) continue;
1840
+ this.flush(batchCount, batchKey);
1841
+ batchCount = 0;
1842
+ batchKey = "";
1843
+ this.writeInstance(
1844
+ 0,
1845
+ transform.x + text.offsetX,
1846
+ transform.y + text.offsetY,
1847
+ entry.w,
1848
+ entry.h,
1849
+ transform.rotation,
1850
+ 0,
1851
+ 0,
1852
+ // anchor top-left
1853
+ 0,
1854
+ 0,
1855
+ false,
1856
+ 1,
1857
+ 1,
1858
+ 1,
1859
+ 1,
1860
+ // white tint — color baked into texture
1861
+ 0,
1862
+ 0,
1863
+ 1,
1864
+ 1
1865
+ );
1866
+ this.flushWithTex(1, entry.tex, true);
1450
1867
  }
1451
1868
  for (const id of world.query("Transform", "ParticlePool")) {
1452
1869
  const t = world.getComponent(id, "Transform");
@@ -1478,86 +1895,106 @@ var RenderSystem = class {
1478
1895
  });
1479
1896
  }
1480
1897
  }
1898
+ let pCount = 0;
1899
+ const pKey = `__color__`;
1481
1900
  for (const p of pool.particles) {
1901
+ if (pCount >= MAX_INSTANCES) {
1902
+ this.flush(pCount, pKey);
1903
+ pCount = 0;
1904
+ }
1482
1905
  const alpha = p.life / p.maxLife;
1483
- ctx.globalAlpha = alpha;
1484
- ctx.fillStyle = p.color;
1485
- ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
1906
+ const [r, g, b] = parseCSSColor(p.color);
1907
+ this.writeInstance(
1908
+ pCount * FLOATS_PER_INSTANCE,
1909
+ p.x,
1910
+ p.y,
1911
+ p.size,
1912
+ p.size,
1913
+ 0,
1914
+ 0.5,
1915
+ 0.5,
1916
+ 0,
1917
+ 0,
1918
+ false,
1919
+ r,
1920
+ g,
1921
+ b,
1922
+ alpha,
1923
+ 0,
1924
+ 0,
1925
+ 1,
1926
+ 1
1927
+ );
1928
+ pCount++;
1486
1929
  }
1487
- ctx.globalAlpha = 1;
1930
+ if (pCount > 0) this.flush(pCount, pKey);
1488
1931
  }
1489
1932
  for (const id of world.query("Transform", "Trail")) {
1490
1933
  const t = world.getComponent(id, "Transform");
1491
1934
  const trail = world.getComponent(id, "Trail");
1492
1935
  trail.points.unshift({ x: t.x, y: t.y });
1493
1936
  if (trail.points.length > trail.length) trail.points.length = trail.length;
1494
- if (trail.points.length < 2) continue;
1495
- for (let i = 1; i < trail.points.length; i++) {
1937
+ if (trail.points.length < 1) continue;
1938
+ const [tr, tg, tb] = parseCSSColor(trail.color);
1939
+ const trailW = trail.width > 0 ? trail.width : 1;
1940
+ let tCount = 0;
1941
+ for (let i = 0; i < trail.points.length; i++) {
1942
+ if (tCount >= MAX_INSTANCES) {
1943
+ this.flush(tCount, "__color__");
1944
+ tCount = 0;
1945
+ }
1496
1946
  const alpha = 1 - i / trail.points.length;
1497
- ctx.save();
1498
- ctx.globalAlpha = alpha;
1499
- ctx.strokeStyle = trail.color;
1500
- ctx.lineWidth = trail.width;
1501
- ctx.lineCap = "round";
1502
- ctx.beginPath();
1503
- ctx.moveTo(trail.points[i - 1].x, trail.points[i - 1].y);
1504
- ctx.lineTo(trail.points[i].x, trail.points[i].y);
1505
- ctx.stroke();
1506
- ctx.restore();
1507
- }
1508
- }
1509
- if (this.debug) {
1510
- ctx.lineWidth = 1;
1511
- for (const id of world.query("Transform", "BoxCollider")) {
1512
- const t = world.getComponent(id, "Transform");
1513
- const c = world.getComponent(id, "BoxCollider");
1514
- ctx.strokeStyle = c.isTrigger ? "rgba(255,200,0,0.6)" : "rgba(0,255,0,0.6)";
1515
- ctx.strokeRect(
1516
- t.x + c.offsetX - c.width / 2,
1517
- t.y + c.offsetY - c.height / 2,
1518
- c.width,
1519
- c.height
1947
+ this.writeInstance(
1948
+ tCount * FLOATS_PER_INSTANCE,
1949
+ trail.points[i].x,
1950
+ trail.points[i].y,
1951
+ trailW,
1952
+ trailW,
1953
+ 0,
1954
+ 0.5,
1955
+ 0.5,
1956
+ 0,
1957
+ 0,
1958
+ false,
1959
+ tr,
1960
+ tg,
1961
+ tb,
1962
+ alpha,
1963
+ 0,
1964
+ 0,
1965
+ 1,
1966
+ 1
1520
1967
  );
1968
+ tCount++;
1521
1969
  }
1970
+ if (tCount > 0) this.flush(tCount, "__color__");
1522
1971
  }
1523
- if (this.debugNavGrid) {
1524
- const g = this.debugNavGrid;
1525
- ctx.lineWidth = 0.5;
1526
- for (let row = 0; row < g.rows; row++) {
1527
- for (let col = 0; col < g.cols; col++) {
1528
- const walkable = g.walkable[row * g.cols + col];
1529
- ctx.fillStyle = walkable ? "rgba(0,255,0,0.08)" : "rgba(255,0,0,0.25)";
1530
- ctx.fillRect(col * g.cellSize, row * g.cellSize, g.cellSize, g.cellSize);
1531
- ctx.strokeStyle = walkable ? "rgba(0,255,0,0.15)" : "rgba(255,0,0,0.35)";
1532
- ctx.strokeRect(col * g.cellSize, row * g.cellSize, g.cellSize, g.cellSize);
1533
- }
1534
- }
1535
- }
1536
- if (this.contactFlashPoints.length > 0) {
1537
- ctx.save();
1538
- for (const pt of this.contactFlashPoints) {
1539
- ctx.beginPath();
1540
- ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2);
1541
- ctx.fillStyle = "rgba(255,80,80,0.9)";
1542
- ctx.fill();
1543
- pt.ttl--;
1544
- }
1545
- ctx.restore();
1546
- this.contactFlashPoints = this.contactFlashPoints.filter((p) => p.ttl > 0);
1547
- }
1548
- ctx.restore();
1549
- if (this.debug && this.frameTimes.length > 0) {
1550
- const avgMs = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
1551
- const fps = Math.round(1e3 / avgMs);
1552
- ctx.save();
1553
- ctx.fillStyle = "rgba(0,0,0,0.5)";
1554
- ctx.fillRect(4, 4, 64, 20);
1555
- ctx.fillStyle = "#00ff00";
1556
- ctx.font = "12px monospace";
1557
- ctx.fillText(`FPS: ${fps}`, 8, 19);
1558
- ctx.restore();
1972
+ }
1973
+ };
1974
+
1975
+ // ../../packages/renderer/src/canvas2d.ts
1976
+ var Canvas2DRenderer = class {
1977
+ constructor(canvas) {
1978
+ this.canvas = canvas;
1979
+ const ctx = canvas.getContext("2d");
1980
+ if (!ctx) throw new Error("Could not get 2D context from canvas");
1981
+ this.ctx = ctx;
1982
+ }
1983
+ ctx;
1984
+ clear(color) {
1985
+ if (color) {
1986
+ this.ctx.fillStyle = color;
1987
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
1988
+ } else {
1989
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1559
1990
  }
1560
1991
  }
1992
+ get width() {
1993
+ return this.canvas.width;
1994
+ }
1995
+ get height() {
1996
+ return this.canvas.height;
1997
+ }
1561
1998
  };
1562
1999
 
1563
2000
  // ../../packages/physics/src/components/rigidbody.ts
@@ -2538,7 +2975,7 @@ function DevToolsOverlay({ handle, loop, ecs, engine }) {
2538
2975
  const selectedEntityData = selectedEntity !== null ? entities.find((e) => e.id === selectedEntity) : null;
2539
2976
  const timings = engine?.systemTimings;
2540
2977
  const fps = (() => {
2541
- const sys = engine?.renderSystem;
2978
+ const sys = engine?.activeRenderSystem;
2542
2979
  if (!sys) return 0;
2543
2980
  const ft = sys.frameTimes ?? [];
2544
2981
  if (ft.length === 0) return 0;
@@ -2795,6 +3232,7 @@ var DebugSystem = class {
2795
3232
  fps = 0;
2796
3233
  update(world, dt) {
2797
3234
  const { ctx, canvas } = this.renderer;
3235
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2798
3236
  this.frameCount++;
2799
3237
  this.lastFpsTime += dt;
2800
3238
  if (this.lastFpsTime >= 0.5) {
@@ -2910,12 +3348,12 @@ function Game({
2910
3348
  asyncAssets = false,
2911
3349
  onReady,
2912
3350
  plugins,
2913
- renderer: CustomRenderer,
2914
3351
  style,
2915
3352
  className,
2916
3353
  children
2917
3354
  }) {
2918
3355
  const canvasRef = useRef3(null);
3356
+ const debugCanvasRef = useRef3(null);
2919
3357
  const wrapperRef = useRef3(null);
2920
3358
  const [engine, setEngine] = useState2(null);
2921
3359
  const [assetsReady, setAssetsReady] = useState2(asyncAssets);
@@ -2929,17 +3367,16 @@ function Game({
2929
3367
  const assets = new AssetManager();
2930
3368
  const physics = new PhysicsSystem(gravity, events);
2931
3369
  const entityIds = /* @__PURE__ */ new Map();
2932
- let canvas2d;
2933
- let builtinRenderSystem;
2934
- let renderSystem;
2935
- if (CustomRenderer) {
2936
- renderSystem = new CustomRenderer(canvas, entityIds);
2937
- } else {
2938
- canvas2d = new Canvas2DRenderer(canvas);
2939
- builtinRenderSystem = new RenderSystem(canvas2d, entityIds);
2940
- renderSystem = builtinRenderSystem;
3370
+ const renderSystem = new RenderSystem(canvas, entityIds);
3371
+ const activeRenderSystem = renderSystem;
3372
+ let debugSystem = null;
3373
+ if (debug) {
3374
+ const debugCanvas2dEl = debugCanvasRef.current;
3375
+ if (debugCanvas2dEl) {
3376
+ const debugCanvas2d = new Canvas2DRenderer(debugCanvas2dEl);
3377
+ debugSystem = new DebugSystem(debugCanvas2d);
3378
+ }
2941
3379
  }
2942
- const debugSystem = debug && canvas2d ? new DebugSystem(canvas2d) : null;
2943
3380
  const systemTimings = /* @__PURE__ */ new Map();
2944
3381
  ecs.addSystem(timedSystem("ScriptSystem", new ScriptSystem(input), systemTimings));
2945
3382
  ecs.addSystem(timedSystem("PhysicsSystem", physics, systemTimings));
@@ -2960,7 +3397,18 @@ function Game({
2960
3397
  handle.onFrame?.();
2961
3398
  }
2962
3399
  });
2963
- const state = { ecs, input, renderer: canvas2d, renderSystem: builtinRenderSystem, physics, events, assets, loop, canvas, entityIds, systemTimings };
3400
+ const state = {
3401
+ ecs,
3402
+ input,
3403
+ activeRenderSystem,
3404
+ physics,
3405
+ events,
3406
+ assets,
3407
+ loop,
3408
+ canvas,
3409
+ entityIds,
3410
+ systemTimings
3411
+ };
2964
3412
  setEngine(state);
2965
3413
  if (plugins) {
2966
3414
  const pluginNames = new Set(plugins.map((p) => p.name));
@@ -2999,6 +3447,11 @@ function Game({
2999
3447
  const s2 = Math.min(scaleX, scaleY);
3000
3448
  canvas.style.transform = `scale(${s2})`;
3001
3449
  canvas.style.transformOrigin = "top left";
3450
+ const debugEl = debugCanvasRef.current;
3451
+ if (debugEl) {
3452
+ debugEl.style.transform = `scale(${s2})`;
3453
+ debugEl.style.transformOrigin = "top left";
3454
+ }
3002
3455
  };
3003
3456
  updateScale();
3004
3457
  resizeObserver = new ResizeObserver(updateScale);
@@ -3060,6 +3513,15 @@ function Game({
3060
3513
  className
3061
3514
  }
3062
3515
  ),
3516
+ debug && /* @__PURE__ */ jsx2(
3517
+ "canvas",
3518
+ {
3519
+ ref: debugCanvasRef,
3520
+ width,
3521
+ height,
3522
+ style: { position: "absolute", top: 0, left: 0, pointerEvents: "none" }
3523
+ }
3524
+ ),
3063
3525
  !assetsReady && /* @__PURE__ */ jsxs2("div", { style: {
3064
3526
  position: "absolute",
3065
3527
  inset: 0,
@@ -4313,7 +4775,13 @@ function useCamera() {
4313
4775
  const engine = useGame();
4314
4776
  return useMemo(() => ({
4315
4777
  shake(intensity, duration) {
4316
- engine.renderSystem?.triggerShake(intensity, duration);
4778
+ const cams = engine.ecs.query("Camera2D");
4779
+ if (cams.length === 0) return;
4780
+ const cam = engine.ecs.getComponent(cams[0], "Camera2D");
4781
+ if (!cam) return;
4782
+ cam.shakeIntensity = intensity;
4783
+ cam.shakeDuration = duration;
4784
+ cam.shakeTimer = duration;
4317
4785
  },
4318
4786
  setFollowOffset(x, y) {
4319
4787
  const camId = engine.ecs.queryOne("Camera2D");
@@ -5233,6 +5701,7 @@ export {
5233
5701
  MovingPlatform,
5234
5702
  ParallaxLayer,
5235
5703
  ParticleEmitter,
5704
+ RenderSystem,
5236
5705
  RigidBody,
5237
5706
  ScreenFlash,
5238
5707
  Script,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeforge",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "React-first 2D browser game engine",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {