cubeforge 0.3.0 → 0.3.2

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.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,400 +1162,893 @@ function createTrail(opts) {
1187
1162
  };
1188
1163
  }
1189
1164
 
1190
- // ../../packages/renderer/src/renderSystem.ts
1191
- var imageCache = /* @__PURE__ */ new Map();
1192
- var RenderSystem = class {
1193
- constructor(renderer, entityIds) {
1194
- this.renderer = renderer;
1195
- this.entityIds = entityIds;
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;
1196
1230
  }
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;
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
+ }
1207
1298
  }
1208
- /** Overlay a nav grid: green = walkable, red = blocked. Pass null to clear. */
1209
- setDebugNavGrid(grid) {
1210
- this.debugNavGrid = grid;
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)}`);
1211
1314
  }
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 });
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)}`);
1215
1327
  }
1216
- triggerShake(intensity, duration) {
1217
- this.pendingShake = { intensity, duration };
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
+ ];
1218
1359
  }
1219
- 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;
1230
- 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");
1236
- 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
- if (cam.followEntityId) {
1244
- const targetId = this.entityIds.get(cam.followEntityId);
1245
- 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);
1250
- if (cam.deadZone) {
1251
- const halfW = cam.deadZone.w / 2;
1252
- 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;
1259
- } else if (cam.smoothing > 0) {
1260
- cam.x += (tx - cam.x) * (1 - cam.smoothing);
1261
- cam.y += (ty - cam.y) * (1 - cam.smoothing);
1262
- } else {
1263
- cam.x = tx;
1264
- cam.y = ty;
1265
- }
1266
- }
1267
- }
1268
- }
1269
- if (cam.bounds) {
1270
- const halfW = canvas.width / (2 * cam.zoom);
1271
- const halfH = canvas.height / (2 * cam.zoom);
1272
- cam.x = Math.max(cam.bounds.x + halfW, Math.min(cam.bounds.x + cam.bounds.width - halfW, cam.x));
1273
- cam.y = Math.max(cam.bounds.y + halfH, Math.min(cam.bounds.y + cam.bounds.height - halfH, cam.y));
1274
- }
1275
- if (cam.shakeTimer > 0) {
1276
- cam.shakeTimer -= dt;
1277
- if (cam.shakeTimer < 0) cam.shakeTimer = 0;
1278
- const progress = cam.shakeDuration > 0 ? cam.shakeTimer / cam.shakeDuration : 0;
1279
- shakeX = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
1280
- shakeY = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
1281
- }
1282
- camX = cam.x;
1283
- camY = cam.y;
1284
- zoom = cam.zoom;
1285
- }
1286
- for (const id of world.query("AnimationState", "Sprite")) {
1287
- const anim = world.getComponent(id, "AnimationState");
1288
- const sprite = world.getComponent(id, "Sprite");
1289
- if (!anim.playing || anim.frames.length === 0) continue;
1290
- anim.timer += dt;
1291
- const frameDuration = 1 / anim.fps;
1292
- while (anim.timer >= frameDuration) {
1293
- anim.timer -= frameDuration;
1294
- anim.currentIndex++;
1295
- 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
- }
1306
- }
1307
- anim.frameEvents?.[anim.currentIndex]?.();
1308
- }
1309
- sprite.frameIndex = anim.frames[anim.currentIndex];
1310
- }
1311
- for (const id of world.query("SquashStretch", "RigidBody")) {
1312
- const ss = world.getComponent(id, "SquashStretch");
1313
- 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;
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
+ }
1366
+ var RenderSystem = class {
1367
+ constructor(canvas, entityIds) {
1368
+ this.canvas = canvas;
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
+ const existing = this.imageCache.get(src);
1504
+ if (existing && existing.complete && existing.naturalWidth > 0) {
1505
+ const gl = this.gl;
1506
+ const tex = gl.createTexture();
1507
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1508
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, existing);
1509
+ gl.generateMipmap(gl.TEXTURE_2D);
1510
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
1511
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1512
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1513
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1514
+ this.textures.set(src, tex);
1515
+ return tex;
1516
+ }
1517
+ if (!existing) {
1518
+ const img = new Image();
1519
+ img.src = src;
1520
+ img.onload = () => {
1521
+ const gl = this.gl;
1522
+ const tex = gl.createTexture();
1523
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1524
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
1525
+ gl.generateMipmap(gl.TEXTURE_2D);
1526
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
1527
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1528
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1529
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1530
+ this.textures.set(src, tex);
1531
+ };
1532
+ this.imageCache.set(src, img);
1319
1533
  }
1320
- this.renderer.clear(background);
1321
- 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);
1357
- }
1358
- } else {
1359
- ctx.drawImage(img, drawX, drawY, imgW, imgH);
1360
- }
1361
- ctx.restore();
1534
+ return this.whiteTexture;
1535
+ }
1536
+ // ── Parallax texture management (REPEAT wrap mode) ────────────────────────
1537
+ loadParallaxTexture(src) {
1538
+ const cached = this.parallaxTextures.get(src);
1539
+ if (cached) return cached;
1540
+ let img = this.parallaxImageCache.get(src);
1541
+ if (!img) {
1542
+ img = new Image();
1543
+ img.src = src;
1544
+ img.onload = () => {
1545
+ const gl = this.gl;
1546
+ const tex = gl.createTexture();
1547
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1548
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
1549
+ gl.generateMipmap(gl.TEXTURE_2D);
1550
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
1551
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1552
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
1553
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
1554
+ this.parallaxTextures.set(src, tex);
1555
+ };
1556
+ this.parallaxImageCache.set(src, img);
1362
1557
  }
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);
1369
- 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
- renderables.sort((a, b) => {
1377
- const sa = world.getComponent(a, "Sprite");
1378
- 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;
1386
- });
1387
- for (const id of renderables) {
1388
- const transform = world.getComponent(id, "Transform");
1389
- const sprite = world.getComponent(id, "Sprite");
1390
- if (!sprite.visible) continue;
1391
- const ss = world.getComponent(id, "SquashStretch");
1392
- const scaleXMod = ss ? ss.currentScaleX : 1;
1393
- 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
1400
- );
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);
1558
+ return null;
1559
+ }
1560
+ // ── Text texture management ───────────────────────────────────────────────
1561
+ getTextTextureKey(text) {
1562
+ return `${text.text}|${text.fontSize ?? 16}|${text.fontFamily ?? "monospace"}|${text.color ?? "#ffffff"}`;
1563
+ }
1564
+ getOrCreateTextTexture(text) {
1565
+ const key = this.getTextTextureKey(text);
1566
+ const cached = this.textureCache.get(key);
1567
+ if (cached) return cached;
1568
+ if (this.textureCache.size >= MAX_TEXT_CACHE) {
1569
+ const oldest = this.textureCacheKeys.shift();
1570
+ if (oldest) {
1571
+ const old = this.textureCache.get(oldest);
1572
+ if (old) this.gl.deleteTexture(old.tex);
1573
+ this.textureCache.delete(oldest);
1428
1574
  }
1429
- ctx.restore();
1430
- }
1431
- const textEntities = world.query("Transform", "Text");
1432
- textEntities.sort((a, b) => {
1433
- const ta = world.getComponent(a, "Text");
1434
- const tb = world.getComponent(b, "Text");
1435
- return ta.zIndex - tb.zIndex;
1436
- });
1437
- for (const id of textEntities) {
1438
- const transform = world.getComponent(id, "Transform");
1439
- const text = world.getComponent(id, "Text");
1440
- 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();
1450
1575
  }
1451
- for (const id of world.query("Transform", "ParticlePool")) {
1452
- const t = world.getComponent(id, "Transform");
1453
- const pool = world.getComponent(id, "ParticlePool");
1454
- pool.particles = pool.particles.filter((p) => {
1455
- p.life -= dt;
1456
- p.x += p.vx * dt;
1457
- p.y += p.vy * dt;
1458
- p.vy += p.gravity * dt;
1459
- return p.life > 0;
1460
- });
1461
- if (pool.active && pool.particles.length < pool.maxParticles) {
1462
- pool.timer += dt;
1463
- const spawnCount = Math.floor(pool.timer * pool.rate);
1464
- pool.timer -= spawnCount / pool.rate;
1465
- for (let i = 0; i < spawnCount && pool.particles.length < pool.maxParticles; i++) {
1466
- const angle = pool.angle + (world.rng() - 0.5) * pool.spread;
1467
- const speed = pool.speed * (0.5 + world.rng() * 0.5);
1468
- pool.particles.push({
1469
- x: t.x,
1470
- y: t.y,
1471
- vx: Math.cos(angle) * speed,
1472
- vy: Math.sin(angle) * speed,
1473
- life: pool.particleLife,
1474
- maxLife: pool.particleLife,
1475
- size: pool.particleSize,
1476
- color: pool.color,
1477
- gravity: pool.gravity
1478
- });
1576
+ const offscreen = document.createElement("canvas");
1577
+ const ctx2d = offscreen.getContext("2d");
1578
+ const font = `${text.fontSize ?? 16}px ${text.fontFamily ?? "monospace"}`;
1579
+ ctx2d.font = font;
1580
+ const metrics = ctx2d.measureText(text.text);
1581
+ const textW = Math.ceil(metrics.width) + 4;
1582
+ const textH = Math.ceil((text.fontSize ?? 16) * 1.5) + 4;
1583
+ offscreen.width = textW;
1584
+ offscreen.height = textH;
1585
+ ctx2d.font = font;
1586
+ ctx2d.fillStyle = text.color ?? "#ffffff";
1587
+ ctx2d.textAlign = "left";
1588
+ ctx2d.textBaseline = "top";
1589
+ ctx2d.fillText(text.text, 2, 2, text.maxWidth);
1590
+ const gl = this.gl;
1591
+ const tex = gl.createTexture();
1592
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1593
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, offscreen);
1594
+ gl.generateMipmap(gl.TEXTURE_2D);
1595
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
1596
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1597
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1598
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1599
+ const entry = { tex, w: textW, h: textH };
1600
+ this.textureCache.set(key, entry);
1601
+ this.textureCacheKeys.push(key);
1602
+ return entry;
1603
+ }
1604
+ // ── Instanced draw call ────────────────────────────────────────────────────
1605
+ flush(count, textureKey) {
1606
+ if (count === 0) return;
1607
+ const { gl } = this;
1608
+ const isColor = textureKey.startsWith("__color__");
1609
+ const tex = isColor ? this.whiteTexture : this.loadTexture(textureKey);
1610
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1611
+ gl.uniform1i(this.uUseTexture, isColor ? 0 : 1);
1612
+ gl.bindVertexArray(this.quadVAO);
1613
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
1614
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
1615
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
1616
+ }
1617
+ flushWithTex(count, tex, useTexture) {
1618
+ if (count === 0) return;
1619
+ const { gl } = this;
1620
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1621
+ gl.uniform1i(this.uUseTexture, useTexture ? 1 : 0);
1622
+ gl.bindVertexArray(this.quadVAO);
1623
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuffer);
1624
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.instanceData, 0, count * FLOATS_PER_INSTANCE);
1625
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
1626
+ }
1627
+ // ── Write one sprite instance into instanceData ───────────────────────────
1628
+ writeInstance(base, x, y, w, h, rot, anchorX, anchorY, offsetX, offsetY, flipX, r, g, b, a, u, v, uw, vh) {
1629
+ const d = this.instanceData;
1630
+ d[base + 0] = x;
1631
+ d[base + 1] = y;
1632
+ d[base + 2] = w;
1633
+ d[base + 3] = h;
1634
+ d[base + 4] = rot;
1635
+ d[base + 5] = anchorX;
1636
+ d[base + 6] = anchorY;
1637
+ d[base + 7] = offsetX;
1638
+ d[base + 8] = offsetY;
1639
+ d[base + 9] = flipX ? 1 : 0;
1640
+ d[base + 10] = r;
1641
+ d[base + 11] = g;
1642
+ d[base + 12] = b;
1643
+ d[base + 13] = a;
1644
+ d[base + 14] = u;
1645
+ d[base + 15] = v;
1646
+ d[base + 16] = uw;
1647
+ d[base + 17] = vh;
1648
+ }
1649
+ // ── Main update loop ───────────────────────────────────────────────────────
1650
+ update(world, dt) {
1651
+ const { gl, canvas } = this;
1652
+ const W = canvas.width;
1653
+ const H = canvas.height;
1654
+ let camX = 0, camY = 0, zoom = 1;
1655
+ let background = "#000000";
1656
+ let shakeX = 0, shakeY = 0;
1657
+ const camId = world.queryOne("Camera2D");
1658
+ if (camId !== void 0) {
1659
+ const cam = world.getComponent(camId, "Camera2D");
1660
+ background = cam.background;
1661
+ if (cam.followEntityId) {
1662
+ const targetId = this.entityIds.get(cam.followEntityId);
1663
+ if (targetId !== void 0) {
1664
+ const t = world.getComponent(targetId, "Transform");
1665
+ if (t) {
1666
+ if (cam.deadZone) {
1667
+ const halfW = cam.deadZone.w / 2;
1668
+ const halfH = cam.deadZone.h / 2;
1669
+ const dx = t.x - cam.x, dy = t.y - cam.y;
1670
+ if (dx > halfW) cam.x = t.x - halfW;
1671
+ else if (dx < -halfW) cam.x = t.x + halfW;
1672
+ if (dy > halfH) cam.y = t.y - halfH;
1673
+ else if (dy < -halfH) cam.y = t.y + halfH;
1674
+ } else if (cam.smoothing > 0) {
1675
+ cam.x += (t.x - cam.x) * (1 - cam.smoothing);
1676
+ cam.y += (t.y - cam.y) * (1 - cam.smoothing);
1677
+ } else {
1678
+ cam.x = t.x;
1679
+ cam.y = t.y;
1680
+ }
1681
+ }
1479
1682
  }
1480
1683
  }
1481
- for (const p of pool.particles) {
1482
- 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);
1684
+ if (cam.bounds) {
1685
+ const halfW = W / (2 * cam.zoom);
1686
+ const halfH = H / (2 * cam.zoom);
1687
+ cam.x = Math.max(cam.bounds.x + halfW, Math.min(cam.bounds.x + cam.bounds.width - halfW, cam.x));
1688
+ cam.y = Math.max(cam.bounds.y + halfH, Math.min(cam.bounds.y + cam.bounds.height - halfH, cam.y));
1486
1689
  }
1487
- ctx.globalAlpha = 1;
1488
- }
1489
- for (const id of world.query("Transform", "Trail")) {
1490
- const t = world.getComponent(id, "Transform");
1491
- const trail = world.getComponent(id, "Trail");
1492
- trail.points.unshift({ x: t.x, y: t.y });
1493
- 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++) {
1496
- 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();
1690
+ if (cam.shakeTimer > 0) {
1691
+ cam.shakeTimer -= dt;
1692
+ if (cam.shakeTimer < 0) cam.shakeTimer = 0;
1693
+ const progress = cam.shakeDuration > 0 ? cam.shakeTimer / cam.shakeDuration : 0;
1694
+ shakeX = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
1695
+ shakeY = (world.rng() * 2 - 1) * cam.shakeIntensity * progress;
1507
1696
  }
1697
+ camX = cam.x;
1698
+ camY = cam.y;
1699
+ zoom = cam.zoom;
1508
1700
  }
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
1520
- );
1701
+ for (const id of world.query("AnimationState", "Sprite")) {
1702
+ const anim = world.getComponent(id, "AnimationState");
1703
+ const sprite = world.getComponent(id, "Sprite");
1704
+ if (!anim.playing || anim.frames.length === 0) continue;
1705
+ anim.timer += dt;
1706
+ const frameDuration = 1 / anim.fps;
1707
+ while (anim.timer >= frameDuration) {
1708
+ anim.timer -= frameDuration;
1709
+ anim.currentIndex++;
1710
+ if (anim.currentIndex >= anim.frames.length) {
1711
+ anim.currentIndex = anim.loop ? 0 : anim.frames.length - 1;
1712
+ }
1521
1713
  }
1714
+ sprite.frameIndex = anim.frames[anim.currentIndex];
1715
+ }
1716
+ for (const id of world.query("SquashStretch", "RigidBody")) {
1717
+ const ss = world.getComponent(id, "SquashStretch");
1718
+ const rb = world.getComponent(id, "RigidBody");
1719
+ const spd = Math.sqrt(rb.vx * rb.vx + rb.vy * rb.vy);
1720
+ const tScX = rb.vy < -100 ? 1 + ss.intensity * 0.4 : spd > 50 ? 1 - ss.intensity * 0.3 : 1;
1721
+ const tScY = rb.vy < -100 ? 1 - ss.intensity * 0.4 : spd > 50 ? 1 + ss.intensity * 0.3 : 1;
1722
+ ss.currentScaleX += (tScX - ss.currentScaleX) * ss.recovery * dt;
1723
+ ss.currentScaleY += (tScY - ss.currentScaleY) * ss.recovery * dt;
1522
1724
  }
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);
1725
+ const [br, bg, bb] = parseCSSColor(background);
1726
+ gl.viewport(0, 0, W, H);
1727
+ gl.clearColor(br, bg, bb, 1);
1728
+ gl.clear(gl.COLOR_BUFFER_BIT);
1729
+ const parallaxEntities = world.query("ParallaxLayer");
1730
+ if (parallaxEntities.length > 0) {
1731
+ parallaxEntities.sort((a, b) => {
1732
+ const za = world.getComponent(a, "ParallaxLayer").zIndex;
1733
+ const zb = world.getComponent(b, "ParallaxLayer").zIndex;
1734
+ return za - zb;
1735
+ });
1736
+ gl.useProgram(this.parallaxProgram);
1737
+ gl.uniform2f(this.pUCanvasSize, W, H);
1738
+ gl.uniform1i(this.pUTexture, 0);
1739
+ gl.activeTexture(gl.TEXTURE0);
1740
+ gl.bindVertexArray(this.parallaxVAO);
1741
+ for (const id of parallaxEntities) {
1742
+ const layer = world.getComponent(id, "ParallaxLayer");
1743
+ let img = this.parallaxImageCache.get(layer.src);
1744
+ if (!img) {
1745
+ this.loadParallaxTexture(layer.src);
1746
+ continue;
1533
1747
  }
1748
+ if (!img.complete || img.naturalWidth === 0) continue;
1749
+ if (layer.imageWidth === 0) layer.imageWidth = img.naturalWidth;
1750
+ if (layer.imageHeight === 0) layer.imageHeight = img.naturalHeight;
1751
+ const tex = this.parallaxTextures.get(layer.src);
1752
+ if (!tex) continue;
1753
+ const imgW = layer.imageWidth;
1754
+ const imgH = layer.imageHeight;
1755
+ const drawX = layer.offsetX - camX * layer.speedX;
1756
+ const drawY = layer.offsetY - camY * layer.speedY;
1757
+ const uvOffsetX = (drawX / imgW % 1 + 1) % 1;
1758
+ const uvOffsetY = (drawY / imgH % 1 + 1) % 1;
1759
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1760
+ gl.uniform2f(this.pUUvOffset, uvOffsetX, uvOffsetY);
1761
+ gl.uniform2f(this.pUTexSize, imgW, imgH);
1762
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1534
1763
  }
1535
1764
  }
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--;
1765
+ gl.useProgram(this.program);
1766
+ gl.uniform2f(this.uCamPos, camX, camY);
1767
+ gl.uniform1f(this.uZoom, zoom);
1768
+ gl.uniform2f(this.uCanvasSize, W, H);
1769
+ gl.uniform2f(this.uShake, shakeX, shakeY);
1770
+ gl.uniform1i(this.uTexture, 0);
1771
+ gl.activeTexture(gl.TEXTURE0);
1772
+ const renderables = world.query("Transform", "Sprite");
1773
+ renderables.sort((a, b) => {
1774
+ const sa = world.getComponent(a, "Sprite");
1775
+ const sb = world.getComponent(b, "Sprite");
1776
+ const zd = sa.zIndex - sb.zIndex;
1777
+ if (zd !== 0) return zd;
1778
+ const ka = getTextureKey(sa), kb = getTextureKey(sb);
1779
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
1780
+ });
1781
+ let batchCount = 0;
1782
+ let batchKey = "";
1783
+ for (let i = 0; i <= renderables.length; i++) {
1784
+ if (i === renderables.length) {
1785
+ this.flush(batchCount, batchKey);
1786
+ break;
1544
1787
  }
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();
1559
- }
1560
- }
1561
- };
1562
-
1563
- // ../../packages/physics/src/components/rigidbody.ts
1564
- function createRigidBody(opts) {
1565
- return {
1566
- type: "RigidBody",
1567
- vx: 0,
1568
- vy: 0,
1569
- mass: 1,
1570
- gravityScale: 1,
1571
- isStatic: false,
1572
- onGround: false,
1573
- isNearGround: false,
1574
- bounce: 0,
1575
- friction: 0.85,
1576
- lockX: false,
1577
- lockY: false,
1578
- isKinematic: false,
1579
- dropThrough: 0,
1580
- ...opts
1581
- };
1582
- }
1583
-
1788
+ const id = renderables[i];
1789
+ const transform = world.getComponent(id, "Transform");
1790
+ const sprite = world.getComponent(id, "Sprite");
1791
+ if (!sprite.visible) continue;
1792
+ if (sprite.image && sprite.image.complete && sprite.image.naturalWidth > 0) {
1793
+ const src = sprite.image.src;
1794
+ if (src && !this.textures.has(src)) {
1795
+ const gl2 = this.gl;
1796
+ const tex = gl2.createTexture();
1797
+ gl2.bindTexture(gl2.TEXTURE_2D, tex);
1798
+ gl2.texImage2D(gl2.TEXTURE_2D, 0, gl2.RGBA, gl2.RGBA, gl2.UNSIGNED_BYTE, sprite.image);
1799
+ gl2.generateMipmap(gl2.TEXTURE_2D);
1800
+ gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_MIN_FILTER, gl2.LINEAR_MIPMAP_LINEAR);
1801
+ gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_MAG_FILTER, gl2.LINEAR);
1802
+ gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_WRAP_S, gl2.CLAMP_TO_EDGE);
1803
+ gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_WRAP_T, gl2.CLAMP_TO_EDGE);
1804
+ this.textures.set(src, tex);
1805
+ }
1806
+ } else if (sprite.src && !sprite.image) {
1807
+ let img = this.imageCache.get(sprite.src);
1808
+ if (!img) {
1809
+ img = new Image();
1810
+ img.src = sprite.src;
1811
+ this.imageCache.set(sprite.src, img);
1812
+ img.onload = () => {
1813
+ const gl2 = this.gl;
1814
+ const tex = gl2.createTexture();
1815
+ gl2.bindTexture(gl2.TEXTURE_2D, tex);
1816
+ gl2.texImage2D(gl2.TEXTURE_2D, 0, gl2.RGBA, gl2.RGBA, gl2.UNSIGNED_BYTE, img);
1817
+ gl2.generateMipmap(gl2.TEXTURE_2D);
1818
+ gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_MIN_FILTER, gl2.LINEAR_MIPMAP_LINEAR);
1819
+ gl2.texParameteri(gl2.TEXTURE_2D, gl2.TEXTURE_MAG_FILTER, gl2.LINEAR);
1820
+ this.textures.set(img.src, tex);
1821
+ };
1822
+ }
1823
+ sprite.image = img;
1824
+ }
1825
+ const key = getTextureKey(sprite);
1826
+ if (key !== batchKey && batchCount > 0 || batchCount >= MAX_INSTANCES) {
1827
+ this.flush(batchCount, batchKey);
1828
+ batchCount = 0;
1829
+ }
1830
+ batchKey = key;
1831
+ const ss = world.getComponent(id, "SquashStretch");
1832
+ const scaleXMod = ss ? ss.currentScaleX : 1;
1833
+ const scaleYMod = ss ? ss.currentScaleY : 1;
1834
+ const [r, g, b, a] = parseCSSColor(sprite.color);
1835
+ const uv = getUVRect(sprite);
1836
+ this.writeInstance(
1837
+ batchCount * FLOATS_PER_INSTANCE,
1838
+ transform.x,
1839
+ transform.y,
1840
+ sprite.width * transform.scaleX * scaleXMod,
1841
+ sprite.height * transform.scaleY * scaleYMod,
1842
+ transform.rotation,
1843
+ sprite.anchorX,
1844
+ sprite.anchorY,
1845
+ sprite.offsetX,
1846
+ sprite.offsetY,
1847
+ sprite.flipX,
1848
+ r,
1849
+ g,
1850
+ b,
1851
+ a,
1852
+ uv[0],
1853
+ uv[1],
1854
+ uv[2],
1855
+ uv[3]
1856
+ );
1857
+ batchCount++;
1858
+ }
1859
+ const textEntities = world.query("Transform", "Text");
1860
+ textEntities.sort((a, b) => {
1861
+ const ta = world.getComponent(a, "Text");
1862
+ const tb = world.getComponent(b, "Text");
1863
+ return ta.zIndex - tb.zIndex;
1864
+ });
1865
+ for (const id of textEntities) {
1866
+ const transform = world.getComponent(id, "Transform");
1867
+ const text = world.getComponent(id, "Text");
1868
+ if (!text.visible) continue;
1869
+ const entry = this.getOrCreateTextTexture(text);
1870
+ if (!entry) continue;
1871
+ this.flush(batchCount, batchKey);
1872
+ batchCount = 0;
1873
+ batchKey = "";
1874
+ this.writeInstance(
1875
+ 0,
1876
+ transform.x + text.offsetX,
1877
+ transform.y + text.offsetY,
1878
+ entry.w,
1879
+ entry.h,
1880
+ transform.rotation,
1881
+ 0,
1882
+ 0,
1883
+ // anchor top-left
1884
+ 0,
1885
+ 0,
1886
+ false,
1887
+ 1,
1888
+ 1,
1889
+ 1,
1890
+ 1,
1891
+ // white tint — color baked into texture
1892
+ 0,
1893
+ 0,
1894
+ 1,
1895
+ 1
1896
+ );
1897
+ this.flushWithTex(1, entry.tex, true);
1898
+ }
1899
+ for (const id of world.query("Transform", "ParticlePool")) {
1900
+ const t = world.getComponent(id, "Transform");
1901
+ const pool = world.getComponent(id, "ParticlePool");
1902
+ pool.particles = pool.particles.filter((p) => {
1903
+ p.life -= dt;
1904
+ p.x += p.vx * dt;
1905
+ p.y += p.vy * dt;
1906
+ p.vy += p.gravity * dt;
1907
+ return p.life > 0;
1908
+ });
1909
+ if (pool.active && pool.particles.length < pool.maxParticles) {
1910
+ pool.timer += dt;
1911
+ const spawnCount = Math.floor(pool.timer * pool.rate);
1912
+ pool.timer -= spawnCount / pool.rate;
1913
+ for (let i = 0; i < spawnCount && pool.particles.length < pool.maxParticles; i++) {
1914
+ const angle = pool.angle + (world.rng() - 0.5) * pool.spread;
1915
+ const speed = pool.speed * (0.5 + world.rng() * 0.5);
1916
+ pool.particles.push({
1917
+ x: t.x,
1918
+ y: t.y,
1919
+ vx: Math.cos(angle) * speed,
1920
+ vy: Math.sin(angle) * speed,
1921
+ life: pool.particleLife,
1922
+ maxLife: pool.particleLife,
1923
+ size: pool.particleSize,
1924
+ color: pool.color,
1925
+ gravity: pool.gravity
1926
+ });
1927
+ }
1928
+ }
1929
+ let pCount = 0;
1930
+ const pKey = `__color__`;
1931
+ for (const p of pool.particles) {
1932
+ if (pCount >= MAX_INSTANCES) {
1933
+ this.flush(pCount, pKey);
1934
+ pCount = 0;
1935
+ }
1936
+ const alpha = p.life / p.maxLife;
1937
+ const [r, g, b] = parseCSSColor(p.color);
1938
+ this.writeInstance(
1939
+ pCount * FLOATS_PER_INSTANCE,
1940
+ p.x,
1941
+ p.y,
1942
+ p.size,
1943
+ p.size,
1944
+ 0,
1945
+ 0.5,
1946
+ 0.5,
1947
+ 0,
1948
+ 0,
1949
+ false,
1950
+ r,
1951
+ g,
1952
+ b,
1953
+ alpha,
1954
+ 0,
1955
+ 0,
1956
+ 1,
1957
+ 1
1958
+ );
1959
+ pCount++;
1960
+ }
1961
+ if (pCount > 0) this.flush(pCount, pKey);
1962
+ }
1963
+ for (const id of world.query("Transform", "Trail")) {
1964
+ const t = world.getComponent(id, "Transform");
1965
+ const trail = world.getComponent(id, "Trail");
1966
+ trail.points.unshift({ x: t.x, y: t.y });
1967
+ if (trail.points.length > trail.length) trail.points.length = trail.length;
1968
+ if (trail.points.length < 1) continue;
1969
+ const [tr, tg, tb] = parseCSSColor(trail.color);
1970
+ const trailW = trail.width > 0 ? trail.width : 1;
1971
+ let tCount = 0;
1972
+ for (let i = 0; i < trail.points.length; i++) {
1973
+ if (tCount >= MAX_INSTANCES) {
1974
+ this.flush(tCount, "__color__");
1975
+ tCount = 0;
1976
+ }
1977
+ const alpha = 1 - i / trail.points.length;
1978
+ this.writeInstance(
1979
+ tCount * FLOATS_PER_INSTANCE,
1980
+ trail.points[i].x,
1981
+ trail.points[i].y,
1982
+ trailW,
1983
+ trailW,
1984
+ 0,
1985
+ 0.5,
1986
+ 0.5,
1987
+ 0,
1988
+ 0,
1989
+ false,
1990
+ tr,
1991
+ tg,
1992
+ tb,
1993
+ alpha,
1994
+ 0,
1995
+ 0,
1996
+ 1,
1997
+ 1
1998
+ );
1999
+ tCount++;
2000
+ }
2001
+ if (tCount > 0) this.flush(tCount, "__color__");
2002
+ }
2003
+ }
2004
+ };
2005
+
2006
+ // ../../packages/renderer/src/canvas2d.ts
2007
+ var Canvas2DRenderer = class {
2008
+ constructor(canvas) {
2009
+ this.canvas = canvas;
2010
+ const ctx = canvas.getContext("2d");
2011
+ if (!ctx) throw new Error("Could not get 2D context from canvas");
2012
+ this.ctx = ctx;
2013
+ }
2014
+ ctx;
2015
+ clear(color) {
2016
+ if (color) {
2017
+ this.ctx.fillStyle = color;
2018
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
2019
+ } else {
2020
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
2021
+ }
2022
+ }
2023
+ get width() {
2024
+ return this.canvas.width;
2025
+ }
2026
+ get height() {
2027
+ return this.canvas.height;
2028
+ }
2029
+ };
2030
+
2031
+ // ../../packages/physics/src/components/rigidbody.ts
2032
+ function createRigidBody(opts) {
2033
+ return {
2034
+ type: "RigidBody",
2035
+ vx: 0,
2036
+ vy: 0,
2037
+ mass: 1,
2038
+ gravityScale: 1,
2039
+ isStatic: false,
2040
+ onGround: false,
2041
+ isNearGround: false,
2042
+ bounce: 0,
2043
+ friction: 0.85,
2044
+ lockX: false,
2045
+ lockY: false,
2046
+ isKinematic: false,
2047
+ dropThrough: 0,
2048
+ ...opts
2049
+ };
2050
+ }
2051
+
1584
2052
  // ../../packages/physics/src/components/boxCollider.ts
1585
2053
  function createBoxCollider(width, height, opts) {
1586
2054
  return {
@@ -2538,7 +3006,7 @@ function DevToolsOverlay({ handle, loop, ecs, engine }) {
2538
3006
  const selectedEntityData = selectedEntity !== null ? entities.find((e) => e.id === selectedEntity) : null;
2539
3007
  const timings = engine?.systemTimings;
2540
3008
  const fps = (() => {
2541
- const sys = engine?.renderSystem;
3009
+ const sys = engine?.activeRenderSystem;
2542
3010
  if (!sys) return 0;
2543
3011
  const ft = sys.frameTimes ?? [];
2544
3012
  if (ft.length === 0) return 0;
@@ -2766,935 +3234,125 @@ function AssetsTab({ assetCache }) {
2766
3234
  /* @__PURE__ */ jsx("span", { style: { color: C.text, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: src.split("/").pop() }),
2767
3235
  loaded && /* @__PURE__ */ jsxs("span", { style: { color: C.muted, fontSize: 9 }, children: [
2768
3236
  img.naturalWidth,
2769
- "\xD7",
2770
- img.naturalHeight
2771
- ] })
2772
- ] }, src);
2773
- })
2774
- ] });
2775
- }
2776
- function formatValue(v) {
2777
- if (typeof v === "number") return v.toFixed(2);
2778
- if (typeof v === "boolean") return v ? "true" : "false";
2779
- if (v === null || v === void 0) return "\u2014";
2780
- if (typeof v === "object") return JSON.stringify(v).slice(0, 60);
2781
- return String(v);
2782
- }
2783
- function getActiveKeys(engine) {
2784
- const kb = engine.input.keyboard;
2785
- return kb.held ? Array.from(kb.held) : [];
2786
- }
2787
-
2788
- // ../devtools/src/debugSystem.ts
2789
- var DebugSystem = class {
2790
- constructor(renderer) {
2791
- this.renderer = renderer;
2792
- }
2793
- frameCount = 0;
2794
- lastFpsTime = 0;
2795
- fps = 0;
2796
- update(world, dt) {
2797
- const { ctx, canvas } = this.renderer;
2798
- ctx.clearRect(0, 0, canvas.width, canvas.height);
2799
- this.frameCount++;
2800
- this.lastFpsTime += dt;
2801
- if (this.lastFpsTime >= 0.5) {
2802
- this.fps = Math.round(this.frameCount / this.lastFpsTime);
2803
- this.frameCount = 0;
2804
- this.lastFpsTime = 0;
2805
- }
2806
- const camId = world.queryOne("Camera2D");
2807
- let camX = 0, camY = 0, zoom = 1;
2808
- if (camId !== void 0) {
2809
- const cam = world.getComponent(camId, "Camera2D");
2810
- camX = cam.x;
2811
- camY = cam.y;
2812
- zoom = cam.zoom;
2813
- }
2814
- ctx.save();
2815
- ctx.translate(canvas.width / 2 - camX * zoom, canvas.height / 2 - camY * zoom);
2816
- ctx.scale(zoom, zoom);
2817
- const lw = 1 / zoom;
2818
- for (const id of world.query("Transform", "BoxCollider")) {
2819
- const t = world.getComponent(id, "Transform");
2820
- const c = world.getComponent(id, "BoxCollider");
2821
- ctx.strokeStyle = c.isTrigger ? "rgba(255,200,0,0.85)" : "rgba(0,255,120,0.85)";
2822
- ctx.lineWidth = lw;
2823
- ctx.strokeRect(
2824
- t.x + c.offsetX - c.width / 2,
2825
- t.y + c.offsetY - c.height / 2,
2826
- c.width,
2827
- c.height
2828
- );
2829
- ctx.fillStyle = "rgba(255,255,255,0.5)";
2830
- ctx.font = `${10 / zoom}px monospace`;
2831
- ctx.fillText(String(id), t.x + c.offsetX - c.width / 2 + lw, t.y + c.offsetY - c.height / 2 - lw * 2);
2832
- }
2833
- if (camId !== void 0) {
2834
- const camFull = world.getComponent(camId, "Camera2D");
2835
- if (camFull.bounds) {
2836
- const b = camFull.bounds;
2837
- ctx.strokeStyle = "rgba(0, 255, 255, 0.4)";
2838
- ctx.lineWidth = 1 / zoom;
2839
- ctx.setLineDash([8 / zoom, 4 / zoom]);
2840
- ctx.strokeRect(b.x, b.y, b.width, b.height);
2841
- ctx.setLineDash([]);
2842
- }
2843
- }
2844
- ctx.restore();
2845
- const GRID_SIZE = 128;
2846
- ctx.save();
2847
- ctx.strokeStyle = "rgba(255, 255, 255, 0.04)";
2848
- ctx.lineWidth = 1;
2849
- ctx.setLineDash([]);
2850
- const offsetX = camX - canvas.width / (2 * zoom);
2851
- const offsetY = camY - canvas.height / (2 * zoom);
2852
- const visibleW = canvas.width / zoom;
2853
- const visibleH = canvas.height / zoom;
2854
- const startCol = Math.floor(offsetX / GRID_SIZE);
2855
- const endCol = Math.ceil((offsetX + visibleW) / GRID_SIZE);
2856
- const startRow = Math.floor(offsetY / GRID_SIZE);
2857
- const endRow = Math.ceil((offsetY + visibleH) / GRID_SIZE);
2858
- ctx.translate(canvas.width / 2 - camX * zoom, canvas.height / 2 - camY * zoom);
2859
- ctx.scale(zoom, zoom);
2860
- for (let col = startCol; col <= endCol; col++) {
2861
- const wx = col * GRID_SIZE;
2862
- ctx.beginPath();
2863
- ctx.moveTo(wx, startRow * GRID_SIZE);
2864
- ctx.lineTo(wx, endRow * GRID_SIZE);
2865
- ctx.stroke();
2866
- }
2867
- for (let row = startRow; row <= endRow; row++) {
2868
- const wy = row * GRID_SIZE;
2869
- ctx.beginPath();
2870
- ctx.moveTo(startCol * GRID_SIZE, wy);
2871
- ctx.lineTo(endCol * GRID_SIZE, wy);
2872
- ctx.stroke();
2873
- }
2874
- ctx.restore();
2875
- const entityCount = world.entityCount;
2876
- const physicsCount = world.query("RigidBody", "BoxCollider").length;
2877
- const renderCount = world.query("Transform", "Sprite").length;
2878
- ctx.save();
2879
- ctx.fillStyle = "rgba(0,0,0,0.65)";
2880
- ctx.fillRect(8, 8, 184, 84);
2881
- ctx.fillStyle = "#00ff88";
2882
- ctx.font = "11px monospace";
2883
- ctx.fillText(`FPS ${this.fps}`, 16, 26);
2884
- ctx.fillText(`Entities ${entityCount}`, 16, 42);
2885
- ctx.fillText(`Physics ${physicsCount}`, 16, 58);
2886
- ctx.fillText(`Renderables ${renderCount}`, 16, 74);
2887
- ctx.restore();
2888
- }
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++;
3237
+ "\xD7",
3238
+ img.naturalHeight
3239
+ ] })
3240
+ ] }, src);
3241
+ })
3242
+ ] });
3243
+ }
3244
+ function formatValue(v) {
3245
+ if (typeof v === "number") return v.toFixed(2);
3246
+ if (typeof v === "boolean") return v ? "true" : "false";
3247
+ if (v === null || v === void 0) return "\u2014";
3248
+ if (typeof v === "object") return JSON.stringify(v).slice(0, 60);
3249
+ return String(v);
3250
+ }
3251
+ function getActiveKeys(engine) {
3252
+ const kb = engine.input.keyboard;
3253
+ return kb.held ? Array.from(kb.held) : [];
3254
+ }
3255
+
3256
+ // ../devtools/src/debugSystem.ts
3257
+ var DebugSystem = class {
3258
+ constructor(renderer) {
3259
+ this.renderer = renderer;
3260
+ }
3261
+ frameCount = 0;
3262
+ lastFpsTime = 0;
3263
+ fps = 0;
3264
+ update(world, dt) {
3265
+ const { ctx, canvas } = this.renderer;
3266
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3267
+ this.frameCount++;
3268
+ this.lastFpsTime += dt;
3269
+ if (this.lastFpsTime >= 0.5) {
3270
+ this.fps = Math.round(this.frameCount / this.lastFpsTime);
3271
+ this.frameCount = 0;
3272
+ this.lastFpsTime = 0;
3553
3273
  }
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);
3274
+ const camId = world.queryOne("Camera2D");
3275
+ let camX = 0, camY = 0, zoom = 1;
3276
+ if (camId !== void 0) {
3277
+ const cam = world.getComponent(camId, "Camera2D");
3278
+ camX = cam.x;
3279
+ camY = cam.y;
3280
+ zoom = cam.zoom;
3593
3281
  }
3594
- for (const id of world.query("Transform", "ParticlePool")) {
3282
+ ctx.save();
3283
+ ctx.translate(canvas.width / 2 - camX * zoom, canvas.height / 2 - camY * zoom);
3284
+ ctx.scale(zoom, zoom);
3285
+ const lw = 1 / zoom;
3286
+ for (const id of world.query("Transform", "BoxCollider")) {
3595
3287
  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);
3288
+ const c = world.getComponent(id, "BoxCollider");
3289
+ ctx.strokeStyle = c.isTrigger ? "rgba(255,200,0,0.85)" : "rgba(0,255,120,0.85)";
3290
+ ctx.lineWidth = lw;
3291
+ ctx.strokeRect(
3292
+ t.x + c.offsetX - c.width / 2,
3293
+ t.y + c.offsetY - c.height / 2,
3294
+ c.width,
3295
+ c.height
3296
+ );
3297
+ ctx.fillStyle = "rgba(255,255,255,0.5)";
3298
+ ctx.font = `${10 / zoom}px monospace`;
3299
+ ctx.fillText(String(id), t.x + c.offsetX - c.width / 2 + lw, t.y + c.offsetY - c.height / 2 - lw * 2);
3657
3300
  }
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++;
3301
+ if (camId !== void 0) {
3302
+ const camFull = world.getComponent(camId, "Camera2D");
3303
+ if (camFull.bounds) {
3304
+ const b = camFull.bounds;
3305
+ ctx.strokeStyle = "rgba(0, 255, 255, 0.4)";
3306
+ ctx.lineWidth = 1 / zoom;
3307
+ ctx.setLineDash([8 / zoom, 4 / zoom]);
3308
+ ctx.strokeRect(b.x, b.y, b.width, b.height);
3309
+ ctx.setLineDash([]);
3695
3310
  }
3696
- if (tCount > 0) this.flush(tCount, "__color__");
3697
3311
  }
3312
+ ctx.restore();
3313
+ const GRID_SIZE = 128;
3314
+ ctx.save();
3315
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.04)";
3316
+ ctx.lineWidth = 1;
3317
+ ctx.setLineDash([]);
3318
+ const offsetX = camX - canvas.width / (2 * zoom);
3319
+ const offsetY = camY - canvas.height / (2 * zoom);
3320
+ const visibleW = canvas.width / zoom;
3321
+ const visibleH = canvas.height / zoom;
3322
+ const startCol = Math.floor(offsetX / GRID_SIZE);
3323
+ const endCol = Math.ceil((offsetX + visibleW) / GRID_SIZE);
3324
+ const startRow = Math.floor(offsetY / GRID_SIZE);
3325
+ const endRow = Math.ceil((offsetY + visibleH) / GRID_SIZE);
3326
+ ctx.translate(canvas.width / 2 - camX * zoom, canvas.height / 2 - camY * zoom);
3327
+ ctx.scale(zoom, zoom);
3328
+ for (let col = startCol; col <= endCol; col++) {
3329
+ const wx = col * GRID_SIZE;
3330
+ ctx.beginPath();
3331
+ ctx.moveTo(wx, startRow * GRID_SIZE);
3332
+ ctx.lineTo(wx, endRow * GRID_SIZE);
3333
+ ctx.stroke();
3334
+ }
3335
+ for (let row = startRow; row <= endRow; row++) {
3336
+ const wy = row * GRID_SIZE;
3337
+ ctx.beginPath();
3338
+ ctx.moveTo(startCol * GRID_SIZE, wy);
3339
+ ctx.lineTo(endCol * GRID_SIZE, wy);
3340
+ ctx.stroke();
3341
+ }
3342
+ ctx.restore();
3343
+ const entityCount = world.entityCount;
3344
+ const physicsCount = world.query("RigidBody", "BoxCollider").length;
3345
+ const renderCount = world.query("Transform", "Sprite").length;
3346
+ ctx.save();
3347
+ ctx.fillStyle = "rgba(0,0,0,0.65)";
3348
+ ctx.fillRect(8, 8, 184, 84);
3349
+ ctx.fillStyle = "#00ff88";
3350
+ ctx.font = "11px monospace";
3351
+ ctx.fillText(`FPS ${this.fps}`, 16, 26);
3352
+ ctx.fillText(`Entities ${entityCount}`, 16, 42);
3353
+ ctx.fillText(`Physics ${physicsCount}`, 16, 58);
3354
+ ctx.fillText(`Renderables ${renderCount}`, 16, 74);
3355
+ ctx.restore();
3698
3356
  }
3699
3357
  };
3700
3358
 
@@ -3721,7 +3379,6 @@ function Game({
3721
3379
  asyncAssets = false,
3722
3380
  onReady,
3723
3381
  plugins,
3724
- renderer: CustomRenderer,
3725
3382
  style,
3726
3383
  className,
3727
3384
  children
@@ -3741,35 +3398,14 @@ function Game({
3741
3398
  const assets = new AssetManager();
3742
3399
  const physics = new PhysicsSystem(gravity, events);
3743
3400
  const entityIds = /* @__PURE__ */ new Map();
3744
- let canvas2d;
3745
- let builtinRenderSystem;
3746
- let renderSystem;
3747
- let activeRenderSystem;
3748
- if (CustomRenderer === "canvas2d") {
3749
- canvas2d = new Canvas2DRenderer(canvas);
3750
- builtinRenderSystem = new RenderSystem(canvas2d, entityIds);
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;
3401
+ const renderSystem = new RenderSystem(canvas, entityIds);
3402
+ const activeRenderSystem = renderSystem;
3765
3403
  let debugSystem = null;
3766
3404
  if (debug) {
3767
3405
  const debugCanvas2dEl = debugCanvasRef.current;
3768
3406
  if (debugCanvas2dEl) {
3769
3407
  const debugCanvas2d = new Canvas2DRenderer(debugCanvas2dEl);
3770
3408
  debugSystem = new DebugSystem(debugCanvas2d);
3771
- } else if (canvas2d) {
3772
- debugSystem = new DebugSystem(canvas2d);
3773
3409
  }
3774
3410
  }
3775
3411
  const systemTimings = /* @__PURE__ */ new Map();
@@ -3795,8 +3431,6 @@ function Game({
3795
3431
  const state = {
3796
3432
  ecs,
3797
3433
  input,
3798
- renderer: canvas2d,
3799
- renderSystem: builtinRenderSystem,
3800
3434
  activeRenderSystem,
3801
3435
  physics,
3802
3436
  events,
@@ -6089,7 +5723,6 @@ export {
6089
5723
  BoxCollider,
6090
5724
  Camera2D,
6091
5725
  CameraZone,
6092
- RenderSystem as Canvas2DRenderSystem,
6093
5726
  CapsuleCollider,
6094
5727
  Checkpoint,
6095
5728
  CircleCollider,
@@ -6099,6 +5732,7 @@ export {
6099
5732
  MovingPlatform,
6100
5733
  ParallaxLayer,
6101
5734
  ParticleEmitter,
5735
+ RenderSystem,
6102
5736
  RigidBody,
6103
5737
  ScreenFlash,
6104
5738
  Script,
@@ -6109,7 +5743,6 @@ export {
6109
5743
  Trail,
6110
5744
  Transform,
6111
5745
  VirtualJoystick,
6112
- WebGLRenderSystem,
6113
5746
  World,
6114
5747
  arrive,
6115
5748
  createAtlas,