nexus-2d 0.0.6 → 0.0.8

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.
@@ -59876,6 +59876,963 @@ class SpriteAtlas {
59876
59876
  }
59877
59877
  }
59878
59878
 
59879
+ // tilemap-profiler.js
59880
+ // Instruments tilemap rendering to expose the waste
59881
+
59882
+ class TilemapProfiler {
59883
+ constructor() {
59884
+ this.reset();
59885
+ }
59886
+
59887
+ reset() {
59888
+ this.frameCount = 0;
59889
+ this.totalPixelsTouched = 0;
59890
+ this.totalBlitCalls = 0;
59891
+ this.totalChunksDrawn = 0;
59892
+ this.cameraMovedFrames = 0;
59893
+ this.cameraStaticFrames = 0;
59894
+ this.lastCameraPos = { x: 0, y: 0 };
59895
+ }
59896
+
59897
+ startFrame(camera) {
59898
+ this.frameCount++;
59899
+
59900
+ // Track if camera moved
59901
+ const moved = camera.worldPosition.x !== this.lastCameraPos.x ||
59902
+ camera.worldPosition.y !== this.lastCameraPos.y;
59903
+
59904
+ if (moved) {
59905
+ this.cameraMovedFrames++;
59906
+ } else {
59907
+ this.cameraStaticFrames++;
59908
+ }
59909
+
59910
+ this.lastCameraPos.x = camera.worldPosition.x;
59911
+ this.lastCameraPos.y = camera.worldPosition.y;
59912
+ }
59913
+
59914
+ recordBlit(width, height) {
59915
+ this.totalBlitCalls++;
59916
+ this.totalPixelsTouched += width * height;
59917
+ }
59918
+
59919
+ recordChunk() {
59920
+ this.totalChunksDrawn++;
59921
+ }
59922
+
59923
+ getReport() {
59924
+ if (this.frameCount === 0) return "No frames recorded";
59925
+
59926
+ const avgPixelsPerFrame = this.totalPixelsTouched / this.frameCount;
59927
+ const avgBlitsPerFrame = this.totalBlitCalls / this.frameCount;
59928
+ const avgChunksPerFrame = this.totalChunksDrawn / this.frameCount;
59929
+ const wasteRatio = this.cameraStaticFrames / this.frameCount;
59930
+
59931
+ return {
59932
+ totalFrames: this.frameCount,
59933
+ cameraMovedFrames: this.cameraMovedFrames,
59934
+ cameraStaticFrames: this.cameraStaticFrames,
59935
+ wasteRatio: (wasteRatio * 100).toFixed(1) + '%',
59936
+ avgPixelsPerFrame: avgPixelsPerFrame.toFixed(0),
59937
+ avgBlitsPerFrame: avgBlitsPerFrame.toFixed(1),
59938
+ avgChunksPerFrame: avgChunksPerFrame.toFixed(1),
59939
+ totalPixelsTouched: this.totalPixelsTouched,
59940
+ wastedPixels: (avgPixelsPerFrame * this.cameraStaticFrames).toFixed(0),
59941
+
59942
+ // The smoking gun
59943
+ redundantWork: `${this.cameraStaticFrames} frames redrew ${avgPixelsPerFrame.toFixed(0)} pixels each for ZERO camera movement`
59944
+ };
59945
+ }
59946
+
59947
+ printReport() {
59948
+ const r = this.getReport();
59949
+ console.log('\n=== TILEMAP RENDER WASTE REPORT ===');
59950
+ console.log(`Total frames analyzed: ${r.totalFrames}`);
59951
+ console.log(`Camera moved: ${r.cameraMovedFrames} frames`);
59952
+ console.log(`Camera static: ${r.cameraStaticFrames} frames`);
59953
+ console.log(`Waste ratio: ${r.wasteRatio} of frames were redundant`);
59954
+ console.log(`\nPer-frame averages:`);
59955
+ console.log(` Pixels touched: ${r.avgPixelsPerFrame}`);
59956
+ console.log(` Blit calls: ${r.avgBlitsPerFrame}`);
59957
+ console.log(` Chunks drawn: ${r.avgChunksPerFrame}`);
59958
+ console.log(`\nTotal waste:`);
59959
+ console.log(` Pixels touched: ${r.totalPixelsTouched.toLocaleString()}`);
59960
+ console.log(` Wasted pixels (static frames): ${r.wastedPixels.toLocaleString()}`);
59961
+ console.log(`\n⚠️ ${r.redundantWork}`);
59962
+ console.log('=====================================\n');
59963
+ }
59964
+ }
59965
+
59966
+ // Singleton instance
59967
+ const tilemapProfiler = new TilemapProfiler();
59968
+
59969
+ CacheBuffer.prototype.markRegion = (...args) => { }; // trickc
59970
+
59971
+
59972
+
59973
+ class CameraTracker {
59974
+ constructor() {
59975
+ this.lastPosition = { x: 0, y: 0 };
59976
+ this.lastZoom = 1.0;
59977
+ this.currentPosition = { x: 0, y: 0 };
59978
+ this.currentZoom = 1.0;
59979
+
59980
+ // Movement stats
59981
+ this.totalFrames = 0;
59982
+ this.staticFrames = 0;
59983
+ this.smallMoveFrames = 0; // < threshold
59984
+ this.largeMoveFrames = 0; // >= threshold
59985
+ this.teleportFrames = 0; // way outside cache
59986
+
59987
+ // Thresholds (pixels)
59988
+ this.smallMoveThreshold = 32; // moves smaller than this are "small"
59989
+ this.teleportThreshold = 256; // moves larger than this are "teleports"
59990
+ }
59991
+
59992
+ // this at start of each frame
59993
+ update(camera) {
59994
+ this.totalFrames++;
59995
+
59996
+ this.currentPosition.x = camera.worldPosition.x;
59997
+ this.currentPosition.y = camera.worldPosition.y;
59998
+ this.currentZoom = camera.zoom || camera.worldZoom || 1.0;
59999
+
60000
+ const delta = this.getDelta();
60001
+ const distance = Math.sqrt(delta.x * delta.x + delta.y * delta.y);
60002
+
60003
+ // Classify movement
60004
+ if (delta.x === 0 && delta.y === 0 && delta.zoom === 0) {
60005
+ this.staticFrames++;
60006
+ } else if (distance < this.smallMoveThreshold) {
60007
+ this.smallMoveFrames++;
60008
+ } else if (distance < this.teleportThreshold) {
60009
+ this.largeMoveFrames++;
60010
+ } else {
60011
+ this.teleportFrames++;
60012
+ }
60013
+ }
60014
+
60015
+ // Get movement delta since last frame
60016
+ getDelta() {
60017
+ return {
60018
+ x: this.currentPosition.x - this.lastPosition.x,
60019
+ y: this.currentPosition.y - this.lastPosition.y,
60020
+ zoom: this.currentZoom - this.lastZoom,
60021
+ magnitude: Math.sqrt(
60022
+ Math.pow(this.currentPosition.x - this.lastPosition.x, 2) +
60023
+ Math.pow(this.currentPosition.y - this.lastPosition.y, 2)
60024
+ )
60025
+ };
60026
+ }
60027
+
60028
+ // Check if camera moved at all
60029
+ hasMoved() {
60030
+ const d = this.getDelta();
60031
+ return d.x !== 0 || d.y !== 0 || d.zoom !== 0;
60032
+ }
60033
+
60034
+ // Check if movement is small enough for incremental update
60035
+ isSmallMove() {
60036
+ return this.getDelta().magnitude < this.smallMoveThreshold;
60037
+ }
60038
+
60039
+ // Check if this is a teleport (need full redraw)
60040
+ isTeleport() {
60041
+ return this.getDelta().magnitude >= this.teleportThreshold;
60042
+ }
60043
+
60044
+ // Check if zoom changed
60045
+ zoomChanged() {
60046
+ return this.currentZoom !== this.lastZoom;
60047
+ }
60048
+
60049
+ // Call at end of frame to commit current position as "last"
60050
+ commit() {
60051
+ this.lastPosition.x = this.currentPosition.x;
60052
+ this.lastPosition.y = this.currentPosition.y;
60053
+ this.lastZoom = this.currentZoom;
60054
+ }
60055
+
60056
+ // Get movement direction (for determining which edge to update)
60057
+ getDirection() {
60058
+ const delta = this.getDelta();
60059
+ const absX = Math.abs(delta.x);
60060
+ const absY = Math.abs(delta.y);
60061
+
60062
+ // Primary direction is the larger component
60063
+ if (absX > absY) {
60064
+ return delta.x > 0 ? 'right' : 'left';
60065
+ } else if (absY > absX) {
60066
+ return delta.y > 0 ? 'down' : 'up';
60067
+ } else if (absX === absY && absX > 0) {
60068
+ // Diagonal - return both directions
60069
+ const h = delta.x > 0 ? 'right' : 'left';
60070
+ const v = delta.y > 0 ? 'down' : 'up';
60071
+ return `${v}-${h}`; // e.g., "down-right"
60072
+ }
60073
+
60074
+ return 'none';
60075
+ }
60076
+
60077
+ // Get stats about camera movement patterns
60078
+ getStats() {
60079
+ if (this.totalFrames === 0) return null;
60080
+
60081
+ return {
60082
+ totalFrames: this.totalFrames,
60083
+ staticFrames: this.staticFrames,
60084
+ smallMoveFrames: this.smallMoveFrames,
60085
+ largeMoveFrames: this.largeMoveFrames,
60086
+ teleportFrames: this.teleportFrames,
60087
+ staticRatio: (this.staticFrames / this.totalFrames * 100).toFixed(1) + '%',
60088
+ smallMoveRatio: (this.smallMoveFrames / this.totalFrames * 100).toFixed(1) + '%',
60089
+ largeMoveRatio: (this.largeMoveFrames / this.totalFrames * 100).toFixed(1) + '%',
60090
+ teleportRatio: (this.teleportFrames / this.totalFrames * 100).toFixed(1) + '%'
60091
+ };
60092
+ }
60093
+
60094
+ printStats() {
60095
+ const s = this.getStats();
60096
+ if (!s) {
60097
+ console.log('No camera movement data yet');
60098
+ return;
60099
+ }
60100
+
60101
+ console.log('\n=== CAMERA MOVEMENT STATS ===');
60102
+ console.log(`Total frames: ${s.totalFrames}`);
60103
+ console.log(`Static (no movement): ${s.staticFrames} (${s.staticRatio})`);
60104
+ console.log(`Small moves (<32px): ${s.smallMoveFrames} (${s.smallMoveRatio})`);
60105
+ console.log(`Large moves (32-256px): ${s.largeMoveFrames} (${s.largeMoveRatio})`);
60106
+ console.log(`Teleports (>256px): ${s.teleportFrames} (${s.teleportRatio})`);
60107
+ console.log('============================\n');
60108
+ }
60109
+ }
60110
+
60111
+
60112
+ class CacheUpdateStrategy {
60113
+ static determine(delta, cacheMargin) {
60114
+ const dist = delta.magnitude;
60115
+
60116
+ // Zoom change always requires full redraw
60117
+ if (delta.zoom !== 0) {
60118
+ return { type: 'FULL_REDRAW', reason: 'zoom changed' };
60119
+ }
60120
+
60121
+ // No movement - no update needed
60122
+ if (dist === 0) {
60123
+ return { type: 'NONE', reason: 'camera static' };
60124
+ }
60125
+
60126
+ // Teleport - full redraw
60127
+ if (dist > cacheMargin * 0.8) {
60128
+ return { type: 'FULL_REDRAW', reason: 'teleport (outside cache margin)' };
60129
+ }
60130
+
60131
+ // Small move - incremental update
60132
+ if (dist < cacheMargin * 0.5) {
60133
+ return {
60134
+ type: 'INCREMENTAL',
60135
+ reason: 'small move (within cache margin)',
60136
+ delta: delta
60137
+ };
60138
+ }
60139
+
60140
+ // Medium move - still incremental but closer to limit
60141
+ return {
60142
+ type: 'INCREMENTAL',
60143
+ reason: 'medium move',
60144
+ delta: delta
60145
+ };
60146
+ }
60147
+ }
60148
+
60149
+ // shifting
60150
+
60151
+ class CacheShifter {
60152
+
60153
+ // Shift buffer content left by dx pixels
60154
+ // This is called when camera moves RIGHT
60155
+ static shiftLeft(buffer, dx) {
60156
+ if (dx <= 0 || dx >= buffer.width) return;
60157
+
60158
+ const data = buffer.data;
60159
+ const width = buffer.width;
60160
+ const height = buffer.height;
60161
+ const shiftPixels = Math.floor(dx);
60162
+
60163
+ // Process row by row
60164
+ // Each row: copy pixels from [shiftPixels...width] to [0...width-shiftPixels]
60165
+ for (let y = 0; y < height; y++) {
60166
+ const rowStart = y * width * 4;
60167
+ const srcStart = rowStart + shiftPixels * 4;
60168
+ const copyWidth = (width - shiftPixels) * 4;
60169
+
60170
+ // Copy row content leftward
60171
+ data.copyWithin(rowStart, srcStart, srcStart + copyWidth);
60172
+ }
60173
+ }
60174
+
60175
+ // Shift buffer content right by dx pixels
60176
+ // Called when camera moves LEFT
60177
+ static shiftRight(buffer, dx) {
60178
+ if (dx <= 0 || dx >= buffer.width) return;
60179
+
60180
+ const data = buffer.data;
60181
+ const width = buffer.width;
60182
+ const height = buffer.height;
60183
+ const shiftPixels = Math.floor(dx);
60184
+
60185
+ // Process row by row, BACKWARDS to avoid overwriting
60186
+ for (let y = height - 1; y >= 0; y--) {
60187
+ const rowStart = y * width * 4;
60188
+ const dstStart = rowStart + shiftPixels * 4;
60189
+ const copyWidth = (width - shiftPixels) * 4;
60190
+
60191
+ // Copy row content rightward
60192
+ data.copyWithin(dstStart, rowStart, rowStart + copyWidth);
60193
+ }
60194
+ }
60195
+
60196
+ // Shift buffer content up by dy pixels
60197
+ // Called when camera moves DOWN
60198
+ static shiftUp(buffer, dy) {
60199
+ if (dy <= 0 || dy >= buffer.height) return;
60200
+
60201
+ const data = buffer.data;
60202
+ const width = buffer.width;
60203
+ const height = buffer.height;
60204
+ const shiftRows = Math.floor(dy);
60205
+ const rowBytes = width * 4;
60206
+
60207
+ // Copy rows upward
60208
+ // Move rows [shiftRows...height] to [0...height-shiftRows]
60209
+ const srcStart = shiftRows * rowBytes;
60210
+ const copyBytes = (height - shiftRows) * rowBytes;
60211
+
60212
+ data.copyWithin(0, srcStart, srcStart + copyBytes);
60213
+ }
60214
+
60215
+ // Shift buffer content down by dy pixels
60216
+ // Called when camera moves UP
60217
+ static shiftDown(buffer, dy) {
60218
+ if (dy <= 0 || dy >= buffer.height) return;
60219
+
60220
+ const data = buffer.data;
60221
+ const width = buffer.width;
60222
+ const height = buffer.height;
60223
+ const shiftRows = Math.floor(dy);
60224
+ const rowBytes = width * 4;
60225
+
60226
+ // Copy rows downward, process backwards to avoid overwrite
60227
+ const srcBytes = (height - shiftRows) * rowBytes;
60228
+ const dstStart = shiftRows * rowBytes;
60229
+
60230
+ data.copyWithin(dstStart, 0, srcBytes);
60231
+ }
60232
+
60233
+ // Combined shift for diagonal movement
60234
+ // Does horizontal first, then vertical
60235
+ static shift(buffer, dx, dy) {
60236
+ // Horizontal shift
60237
+ if (dx > 0) {
60238
+ this.shiftLeft(buffer, dx);
60239
+ } else if (dx < 0) {
60240
+ this.shiftRight(buffer, -dx);
60241
+ }
60242
+
60243
+ // Vertical shift
60244
+ if (dy > 0) {
60245
+ this.shiftUp(buffer, dy);
60246
+ } else if (dy < 0) {
60247
+ this.shiftDown(buffer, -dy);
60248
+ }
60249
+ }
60250
+ }
60251
+
60252
+ // ============= THE FIX =============
60253
+ // The key insight: we need to calculate world regions BEFORE updating worldX/worldY
60254
+ // because the regions represent what NEW content to draw, relative to the OLD cache position
60255
+
60256
+ class ShiftRegionCalculator {
60257
+ static calculateNewRegions(oldWorldX, oldWorldY, cacheWidth, cacheHeight, deltaX, deltaY) {
60258
+ const regions = [];
60259
+
60260
+ // deltaX/Y represent camera movement in world space
60261
+ // We calculate which regions of the NEW cache bounds need fresh rendering
60262
+
60263
+ if (deltaX > 0) {
60264
+ // Camera moved right → old cache was at oldWorldX, now at oldWorldX + deltaX
60265
+ // The NEW right edge shows world content that wasn't in the old cache
60266
+ // World coordinates: from (oldWorldX + cacheWidth) to (oldWorldX + cacheWidth + deltaX)
60267
+ regions.push({
60268
+ cache: {
60269
+ x: cacheWidth - Math.floor(deltaX),
60270
+ y: 0,
60271
+ width: Math.floor(deltaX),
60272
+ height: cacheHeight
60273
+ },
60274
+ world: {
60275
+ x: oldWorldX + cacheWidth, // Right edge of OLD cache
60276
+ y: oldWorldY,
60277
+ width: Math.floor(deltaX),
60278
+ height: cacheHeight
60279
+ },
60280
+ edge: 'right'
60281
+ });
60282
+ } else if (deltaX < 0) {
60283
+ // Camera moved left → new left edge needs rendering
60284
+ const absDx = Math.floor(-deltaX);
60285
+ regions.push({
60286
+ cache: {
60287
+ x: 0,
60288
+ y: 0,
60289
+ width: absDx,
60290
+ height: cacheHeight
60291
+ },
60292
+ world: {
60293
+ x: oldWorldX + deltaX, // New left edge in world space
60294
+ y: oldWorldY,
60295
+ width: absDx,
60296
+ height: cacheHeight
60297
+ },
60298
+ edge: 'left'
60299
+ });
60300
+ }
60301
+
60302
+ if (deltaY > 0) {
60303
+ // Camera moved down → new bottom edge
60304
+ const absDy = Math.floor(deltaY);
60305
+ regions.push({
60306
+ cache: {
60307
+ x: 0,
60308
+ y: cacheHeight - absDy,
60309
+ width: cacheWidth,
60310
+ height: absDy
60311
+ },
60312
+ world: {
60313
+ x: oldWorldX,
60314
+ y: oldWorldY + cacheHeight, // Bottom edge of OLD cache
60315
+ width: cacheWidth,
60316
+ height: absDy
60317
+ },
60318
+ edge: 'bottom'
60319
+ });
60320
+ } else if (deltaY < 0) {
60321
+ // Camera moved up → new top edge
60322
+ const absDy = Math.floor(-deltaY);
60323
+ regions.push({
60324
+ cache: {
60325
+ x: 0,
60326
+ y: 0,
60327
+ width: cacheWidth,
60328
+ height: absDy
60329
+ },
60330
+ world: {
60331
+ x: oldWorldX,
60332
+ y: oldWorldY + deltaY, // New top edge in world space
60333
+ width: cacheWidth,
60334
+ height: absDy
60335
+ },
60336
+ edge: 'top'
60337
+ });
60338
+ }
60339
+
60340
+ return regions;
60341
+ }
60342
+ }
60343
+
60344
+
60345
+ class FullMapPreRender {
60346
+ constructor(mapWidth, mapHeight) {
60347
+ this.mapWidth = mapWidth;
60348
+ this.mapHeight = mapHeight;
60349
+ this.buffer = new CacheBuffer(mapWidth, mapHeight);
60350
+ this.rendered = false;
60351
+ this.pixelsDrawn = 0;
60352
+
60353
+ console.log(`[FullMapPreRender] Created ${mapWidth}x${mapHeight} full-map buffer`);
60354
+ }
60355
+
60356
+ render(renderCallback) {
60357
+ if (this.rendered) return;
60358
+
60359
+ console.log(`[FullMapPreRender] Rendering full map...`);
60360
+ this.buffer.clear(0, 0, 0, 0);
60361
+
60362
+ renderCallback(this.buffer);
60363
+ this.pixelsDrawn = this.mapWidth * this.mapHeight;
60364
+ this.rendered = true;
60365
+ }
60366
+
60367
+ copyToDisplay(displayCanvas, camera) {
60368
+ const cameraWorldX = camera.worldPosition.x;
60369
+ const cameraWorldY = camera.worldPosition.y;
60370
+
60371
+ // offset within full map buffer where camera is looking
60372
+ const bufferOffsetX = cameraWorldX;
60373
+ const bufferOffsetY = cameraWorldY;
60374
+
60375
+ this._blitRegion(
60376
+ this.buffer,
60377
+ bufferOffsetX,
60378
+ bufferOffsetY,
60379
+ displayCanvas.width,
60380
+ displayCanvas.height,
60381
+ displayCanvas,
60382
+ 0,
60383
+ 0
60384
+ );
60385
+ }
60386
+
60387
+ _blitRegion(srcBuffer, srcX, srcY, width, height, dstCanvas, dstX, dstY) {
60388
+ const srcData = srcBuffer.data;
60389
+ const dstData = dstCanvas.data;
60390
+ const srcW = srcBuffer.width;
60391
+ const srcH = srcBuffer.height;
60392
+ const dstW = dstCanvas.width;
60393
+ const dstH = dstCanvas.height;
60394
+
60395
+ // Clamp source bounds
60396
+ const srcStartX = Math.max(0, Math.floor(srcX));
60397
+ const srcStartY = Math.max(0, Math.floor(srcY));
60398
+ const srcEndX = Math.min(srcW, Math.ceil(srcX + width));
60399
+ const srcEndY = Math.min(srcH, Math.ceil(srcY + height));
60400
+
60401
+ // Clamp destination bounds
60402
+ const dstStartX = Math.max(0, Math.floor(dstX));
60403
+ const dstStartY = Math.max(0, Math.floor(dstY));
60404
+ const dstEndX = Math.min(dstW, Math.floor(dstX + (srcEndX - srcStartX)));
60405
+ const dstEndY = Math.min(dstH, Math.floor(dstY + (srcEndY - srcStartY)));
60406
+
60407
+ const copyWidth = dstEndX - dstStartX;
60408
+ const copyHeight = dstEndY - dstStartY;
60409
+
60410
+ if (copyWidth <= 0 || copyHeight <= 0) return;
60411
+
60412
+ // Adjust source start if destination was clamped
60413
+ const srcOffsetX = dstStartX - Math.floor(dstX);
60414
+ const srcOffsetY = dstStartY - Math.floor(dstY);
60415
+
60416
+ // Fast row-by-row copy
60417
+ for (let y = 0; y < copyHeight; y++) {
60418
+ const srcRowStart = ((srcStartY + srcOffsetY + y) * srcW + srcStartX + srcOffsetX) * 4;
60419
+ const dstRowStart = ((dstStartY + y) * dstW + dstStartX) * 4;
60420
+
60421
+ dstData.set(
60422
+ srcData.subarray(srcRowStart, srcRowStart + copyWidth * 4),
60423
+ dstRowStart
60424
+ );
60425
+ }
60426
+
60427
+ dstCanvas.markRegion(dstStartX, dstStartY, copyWidth, copyHeight);
60428
+ }
60429
+
60430
+ getStats() {
60431
+ return {
60432
+ mapSize: `${this.mapWidth}x${this.mapHeight}`,
60433
+ rendered: this.rendered,
60434
+ pixelsDrawn: this.pixelsDrawn
60435
+ };
60436
+ }
60437
+
60438
+ printStats() {
60439
+ const s = this.getStats();
60440
+ console.log('FULL MAP PRE-RENDER STATS');
60441
+ console.log(`Map size: ${s.mapSize}`);
60442
+ console.log(`Rendered: ${s.rendered}`);
60443
+ console.log(`Pixels drawn: ${s.pixelsDrawn}`);
60444
+ console.log('=============================\n');
60445
+ }
60446
+ }
60447
+
60448
+
60449
+ class OffscreenTilemapCache {
60450
+ constructor(viewportWidth, viewportHeight, marginRatio = 0.25) {
60451
+ this.viewportWidth = viewportWidth;
60452
+ this.viewportHeight = viewportHeight;
60453
+ this.marginRatio = marginRatio;
60454
+ const marginX = Math.ceil(viewportWidth * marginRatio);
60455
+ const marginY = Math.ceil(viewportHeight * marginRatio);
60456
+
60457
+ this.cacheWidth = viewportWidth + marginX * 2;
60458
+ this.cacheHeight = viewportHeight + marginY * 2;
60459
+ this.marginPixels = { x: marginX, y: marginY };
60460
+
60461
+ this.buffer = new CacheBuffer(this.cacheWidth, this.cacheHeight);
60462
+
60463
+ this.worldX = 0;
60464
+ this.worldY = 0;
60465
+
60466
+
60467
+ this.dirty = true;
60468
+
60469
+ this.tracker = new CameraTracker();
60470
+
60471
+ this.fullRedraws = 0;
60472
+ this.incrementalUpdates = 0;
60473
+ this.copies = 0;
60474
+ this.skippedUpdates = 0;
60475
+ this.pixelsShifted = 0;
60476
+ this.pixelsDrawn = 0;
60477
+
60478
+ console.log(`[OffscreenCache] Created ${this.cacheWidth}x${this.cacheHeight} cache for ${viewportWidth}x${viewportHeight} viewport`);
60479
+ }
60480
+
60481
+ needsUpdate(camera) {
60482
+ this.tracker.update(camera);
60483
+ const delta = this.tracker.getDelta();
60484
+
60485
+ // Use margin size as threshold for determining update type
60486
+ const marginSize = Math.min(this.marginPixels.x, this.marginPixels.y);
60487
+ return CacheUpdateStrategy.determine(delta, marginSize);
60488
+
60489
+ }
60490
+
60491
+ incrementalUpdate(delta, renderCallback) {
60492
+ const dx = delta.x;
60493
+ const dy = delta.y;
60494
+
60495
+ // STEP 1: Calculate regions BEFORE updating worldX/worldY
60496
+ // This is the fix - we need the OLD cache position to know what's NEW
60497
+ const oldWorldX = this.worldX;
60498
+ const oldWorldY = this.worldY;
60499
+
60500
+ const newRegions = ShiftRegionCalculator.calculateNewRegions(
60501
+ oldWorldX,
60502
+ oldWorldY,
60503
+ this.cacheWidth,
60504
+ this.cacheHeight,
60505
+ dx,
60506
+ dy
60507
+ );
60508
+
60509
+ // STEP 2: Shift buffer content
60510
+ CacheShifter.shift(this.buffer, dx, dy);
60511
+ this.pixelsShifted += this.cacheWidth * this.cacheHeight;
60512
+
60513
+ // STEP 3: Render new regions (BEFORE updating world bounds)
60514
+ // Each region now has BOTH cache coords and world coords pre-calculated
60515
+ for (const region of newRegions) {
60516
+ this.pixelsDrawn += region.cache.width * region.cache.height;
60517
+ renderCallback(region.cache, region.world);
60518
+ }
60519
+
60520
+ // STEP 4: Update world bounds (NOW, after rendering)
60521
+ this.worldX += dx;
60522
+ this.worldY += dy;
60523
+
60524
+ this.incrementalUpdates++;
60525
+ }
60526
+
60527
+ commitFrame() {
60528
+ this.tracker.commit();
60529
+ }
60530
+
60531
+ invalidate() {
60532
+ this.dirty = true;
60533
+ }
60534
+
60535
+ setWorldRegion(worldX, worldY) {
60536
+ this.worldX = worldX;
60537
+ this.worldY = worldY;
60538
+ }
60539
+
60540
+ getWorldBounds() {
60541
+ return {
60542
+ left: this.worldX,
60543
+ top: this.worldY,
60544
+ right: this.worldX + this.cacheWidth,
60545
+ bottom: this.worldY + this.cacheHeight,
60546
+ width: this.cacheWidth,
60547
+ height: this.cacheHeight
60548
+ };
60549
+ }
60550
+
60551
+ isCameraInBounds(camera) {
60552
+ const bounds = this.getWorldBounds();
60553
+ const camX = camera.worldPosition.x;
60554
+ const camY = camera.worldPosition.y;
60555
+
60556
+ // Camera should stay within the inner viewport area, not near edges
60557
+ const safeLeft = bounds.left + this.marginPixels.x;
60558
+ const safeRight = bounds.right - this.marginPixels.x;
60559
+ const safeTop = bounds.top + this.marginPixels.y;
60560
+ const safeBottom = bounds.bottom - this.marginPixels.y;
60561
+
60562
+ return camX >= safeLeft && camX <= safeRight &&
60563
+ camY >= safeTop && camY <= safeBottom;
60564
+ }
60565
+
60566
+ clear() {
60567
+ this.buffer.clear(0, 0, 0, 0);
60568
+ }
60569
+
60570
+ copyToDisplay(displayCanvas, camera) {
60571
+ this.copies++;
60572
+
60573
+
60574
+ const cameraWorldX = camera.worldPosition.x;
60575
+ const cameraWorldY = camera.worldPosition.y;
60576
+
60577
+ // offset within cache where camera is looking
60578
+ const cacheOffsetX = cameraWorldX - this.worldX;
60579
+ const cacheOffsetY = cameraWorldY - this.worldY;
60580
+
60581
+
60582
+ this._blitRegion(
60583
+ this.buffer,
60584
+ cacheOffsetX,
60585
+ cacheOffsetY,
60586
+ this.viewportWidth,
60587
+ this.viewportHeight,
60588
+ displayCanvas,
60589
+ 0,
60590
+ 0
60591
+ );
60592
+ }
60593
+
60594
+ // Fast blit from cache to display - assumes opaque
60595
+ _blitRegion(srcBuffer, srcX, srcY, width, height, dstCanvas, dstX, dstY) {
60596
+ const srcData = srcBuffer.data;
60597
+ const dstData = dstCanvas.data;
60598
+ const srcW = srcBuffer.width;
60599
+ const srcH = srcBuffer.height;
60600
+ const dstW = dstCanvas.width;
60601
+ const dstH = dstCanvas.height;
60602
+
60603
+ // Clamp source bounds
60604
+ const srcStartX = Math.max(0, Math.floor(srcX));
60605
+ const srcStartY = Math.max(0, Math.floor(srcY));
60606
+ const srcEndX = Math.min(srcW, Math.ceil(srcX + width));
60607
+ const srcEndY = Math.min(srcH, Math.ceil(srcY + height));
60608
+
60609
+ // Clamp destination bounds
60610
+ const dstStartX = Math.max(0, Math.floor(dstX));
60611
+ const dstStartY = Math.max(0, Math.floor(dstY));
60612
+ const dstEndX = Math.min(dstW, Math.floor(dstX + (srcEndX - srcStartX)));
60613
+ const dstEndY = Math.min(dstH, Math.floor(dstY + (srcEndY - srcStartY)));
60614
+
60615
+ const copyWidth = dstEndX - dstStartX;
60616
+ const copyHeight = dstEndY - dstStartY;
60617
+
60618
+ if (copyWidth <= 0 || copyHeight <= 0) return;
60619
+
60620
+ // Adjust source start if destination was clamped
60621
+ const srcOffsetX = dstStartX - Math.floor(dstX);
60622
+ const srcOffsetY = dstStartY - Math.floor(dstY);
60623
+
60624
+ // Fast row-by-row copy
60625
+ for (let y = 0; y < copyHeight; y++) {
60626
+ const srcRowStart = ((srcStartY + srcOffsetY + y) * srcW + srcStartX + srcOffsetX) * 4;
60627
+ const dstRowStart = ((dstStartY + y) * dstW + dstStartX) * 4;
60628
+
60629
+ dstData.set(
60630
+ srcData.subarray(srcRowStart, srcRowStart + copyWidth * 4),
60631
+ dstRowStart
60632
+ );
60633
+ }
60634
+
60635
+ dstCanvas.markRegion(dstStartX, dstStartY, copyWidth, copyHeight);
60636
+ }
60637
+
60638
+ getStats() {
60639
+ return {
60640
+ cacheSize: `${this.cacheWidth}x${this.cacheHeight}`,
60641
+ viewportSize: `${this.viewportWidth}x${this.viewportHeight}`,
60642
+ marginRatio: this.marginRatio,
60643
+ fullRedraws: this.fullRedraws,
60644
+ copies: this.copies,
60645
+ worldRegion: `(${this.worldX}, ${this.worldY}) to (${this.worldX + this.cacheWidth}, ${this.worldY + this.cacheHeight})`
60646
+ };
60647
+ }
60648
+
60649
+ printStats() {
60650
+ const s = this.getStats();
60651
+ console.log('OFFSCREEN CACHE STATS');
60652
+ console.log(`Cache size: ${s.cacheSize}`);
60653
+ console.log(`Viewport size: ${s.viewportSize}`);
60654
+ console.log(`Margin: ${(this.marginRatio * 100).toFixed(0)}%`);
60655
+ console.log(`Full redraws: ${s.fullRedraws}`);
60656
+ console.log(`Cache copies: ${s.copies}`);
60657
+ console.log(`World region: ${s.worldRegion}`);
60658
+ console.log('=============================\n');
60659
+ }
60660
+ }
60661
+
60662
+
60663
+ // ============= STRATEGY 3: CHUNKED PRE-RENDER =============
60664
+ // Divide world into fixed-size chunks, pre-render all layers into each chunk,
60665
+ // then just copy visible chunks to display. No shifting, no artifacts.
60666
+
60667
+ class ChunkedPreRender {
60668
+ constructor(mapWidth, mapHeight, chunkSize = 256) {
60669
+ this.mapWidth = mapWidth;
60670
+ this.mapHeight = mapHeight;
60671
+ this.chunkSize = chunkSize;
60672
+
60673
+ // Calculate grid dimensions
60674
+ this.chunksX = Math.ceil(mapWidth / chunkSize);
60675
+ this.chunksY = Math.ceil(mapHeight / chunkSize);
60676
+
60677
+ // Store rendered chunk buffers in a 2D grid
60678
+ this.chunks = new Array(this.chunksY);
60679
+ for (let y = 0; y < this.chunksY; y++) {
60680
+ this.chunks[y] = new Array(this.chunksX);
60681
+ }
60682
+
60683
+ this.renderCallbacks = [];
60684
+ this.rendered = false;
60685
+ this.pixelsDrawn = 0;
60686
+
60687
+ console.log(`[ChunkedPreRender] Created ${this.chunksX}x${this.chunksY} chunk grid (${chunkSize}x${chunkSize}px chunks) for ${mapWidth}x${mapHeight} map`);
60688
+ }
60689
+
60690
+ // Register a callback that will render layers to a buffer
60691
+ // Called during initialization and when chunks need updating
60692
+ onRenderChunk(callback) {
60693
+ this.renderCallbacks.push(callback);
60694
+ }
60695
+
60696
+ // Pre-render all chunks (call once during init)
60697
+ renderAllChunks() {
60698
+ if (this.rendered) return;
60699
+
60700
+ console.log(`[ChunkedPreRender] Pre-rendering ${this.chunksX * this.chunksY} chunks...`);
60701
+
60702
+ for (let cy = 0; cy < this.chunksY; cy++) {
60703
+ for (let cx = 0; cx < this.chunksX; cx++) {
60704
+ const chunk = this._renderChunk(cx, cy);
60705
+ this.chunks[cy][cx] = chunk;
60706
+ this.pixelsDrawn += chunk.buffer.width * chunk.buffer.height;
60707
+ }
60708
+ }
60709
+
60710
+ this.rendered = true;
60711
+ console.log(`[ChunkedPreRender] Pre-render complete. Total pixels: ${this.pixelsDrawn}`);
60712
+ }
60713
+
60714
+ // Render a single chunk
60715
+ _renderChunk(chunkX, chunkY) {
60716
+ // Calculate world bounds for this chunk
60717
+ const worldX = chunkX * this.chunkSize;
60718
+ const worldY = chunkY * this.chunkSize;
60719
+
60720
+ // Calculate actual chunk size (may be smaller at map edges)
60721
+ const chunkWidth = Math.min(this.chunkSize, this.mapWidth - worldX);
60722
+ const chunkHeight = Math.min(this.chunkSize, this.mapHeight - worldY);
60723
+
60724
+ // Create buffer for this chunk
60725
+ const buffer = new CacheBuffer(chunkWidth, chunkHeight);
60726
+ buffer.clear(0, 0, 0, 0);
60727
+
60728
+ // Call all registered render callbacks with this chunk's world region
60729
+ for (const callback of this.renderCallbacks) {
60730
+ callback(buffer, {
60731
+ x: worldX,
60732
+ y: worldY,
60733
+ width: chunkWidth,
60734
+ height: chunkHeight
60735
+ });
60736
+ }
60737
+
60738
+ return {
60739
+ buffer,
60740
+ worldX,
60741
+ worldY,
60742
+ width: chunkWidth,
60743
+ height: chunkHeight
60744
+ };
60745
+ }
60746
+
60747
+ // Copy visible chunks to display canvas based on camera position
60748
+ copyToDisplay(displayCanvas, camera) {
60749
+ const cameraX = camera.worldPosition.x;
60750
+ const cameraY = camera.worldPosition.y;
60751
+ const viewWidth = displayCanvas.width;
60752
+ const viewHeight = displayCanvas.height;
60753
+
60754
+ // Calculate which chunks are visible
60755
+ // Camera position is top-left of viewport
60756
+ const minChunkX = Math.max(0, Math.floor(cameraX / this.chunkSize));
60757
+ const maxChunkX = Math.min(this.chunksX - 1, Math.floor((cameraX + viewWidth) / this.chunkSize));
60758
+ const minChunkY = Math.max(0, Math.floor(cameraY / this.chunkSize));
60759
+ const maxChunkY = Math.min(this.chunksY - 1, Math.floor((cameraY + viewHeight) / this.chunkSize));
60760
+
60761
+ // Blit each visible chunk to canvas
60762
+ for (let cy = minChunkY; cy <= maxChunkY; cy++) {
60763
+ for (let cx = minChunkX; cx <= maxChunkX; cx++) {
60764
+ const chunk = this.chunks[cy][cx];
60765
+ if (!chunk) continue;
60766
+
60767
+ // Calculate destination position on canvas
60768
+ // chunk.worldX is in world space, convert to screen space (relative to camera)
60769
+ const screenX = chunk.worldX - cameraX;
60770
+ const screenY = chunk.worldY - cameraY;
60771
+
60772
+ this._blitChunk(chunk.buffer, screenX, screenY, displayCanvas);
60773
+ }
60774
+ }
60775
+ }
60776
+
60777
+ // Blit a single chunk buffer to the display canvas
60778
+ _blitChunk(srcBuffer, dstX, dstY, dstCanvas) {
60779
+ const srcData = srcBuffer.data;
60780
+ const dstData = dstCanvas.data;
60781
+ const srcW = srcBuffer.width;
60782
+ const srcH = srcBuffer.height;
60783
+ const dstW = dstCanvas.width;
60784
+ const dstH = dstCanvas.height;
60785
+
60786
+ // Clamp destination bounds
60787
+ const dstStartX = Math.max(0, Math.floor(dstX));
60788
+ const dstStartY = Math.max(0, Math.floor(dstY));
60789
+ const dstEndX = Math.min(dstW, Math.floor(dstX + srcW));
60790
+ const dstEndY = Math.min(dstH, Math.floor(dstY + srcH));
60791
+
60792
+ if (dstStartX >= dstEndX || dstStartY >= dstEndY) return;
60793
+
60794
+ // Calculate corresponding source region (clipping)
60795
+ const srcStartX = Math.max(0, Math.floor(-dstX));
60796
+ const srcStartY = Math.max(0, Math.floor(-dstY));
60797
+ const copyWidth = dstEndX - dstStartX;
60798
+ const copyHeight = dstEndY - dstStartY;
60799
+
60800
+ // Fast row-by-row copy
60801
+ for (let y = 0; y < copyHeight; y++) {
60802
+ const srcRowStart = ((srcStartY + y) * srcW + srcStartX) * 4;
60803
+ const dstRowStart = ((dstStartY + y) * dstW + dstStartX) * 4;
60804
+
60805
+ dstData.set(
60806
+ srcData.subarray(srcRowStart, srcRowStart + copyWidth * 4),
60807
+ dstRowStart
60808
+ );
60809
+ }
60810
+
60811
+ dstCanvas.markRegion(dstStartX, dstStartY, copyWidth, copyHeight);
60812
+ }
60813
+
60814
+ getStats() {
60815
+ return {
60816
+ mapSize: `${this.mapWidth}x${this.mapHeight}`,
60817
+ chunkSize: this.chunkSize,
60818
+ chunkGrid: `${this.chunksX}x${this.chunksY}`,
60819
+ rendered: this.rendered,
60820
+ pixelsDrawn: this.pixelsDrawn
60821
+ };
60822
+ }
60823
+
60824
+ printStats() {
60825
+ const s = this.getStats();
60826
+ console.log('=== CHUNKED PRE-RENDER STATS ===');
60827
+ console.log(`Map size: ${s.mapSize}`);
60828
+ console.log(`Chunk size: ${s.chunkSize}`);
60829
+ console.log(`Chunk grid: ${s.chunkGrid}`);
60830
+ console.log(`Rendered: ${s.rendered}`);
60831
+ console.log(`Pixels drawn: ${s.pixelsDrawn}`);
60832
+ console.log('==================================\n');
60833
+ }
60834
+ }
60835
+
59879
60836
  const tiledperf = globalPerf;
59880
60837
 
59881
60838
  function reshapeArray(arr, width, height) {
@@ -60009,6 +60966,7 @@ class TileLayer extends BaseLayer {
60009
60966
  }
60010
60967
 
60011
60968
  renderChunkToBuffer(chunkX, chunkY, buffer) {
60969
+ tiledperf.start("renderChunkToBuffer");
60012
60970
  const startX = chunkX * this.chunkSize;
60013
60971
  const startY = chunkY * this.chunkSize;
60014
60972
  const endX = Math.min(startX + this.chunkSize, this.layerwidth);
@@ -60046,9 +61004,11 @@ class TileLayer extends BaseLayer {
60046
61004
  });
60047
61005
  }
60048
61006
  }
61007
+ tiledperf.end("renderChunkToBuffer");
60049
61008
  }
60050
61009
 
60051
61010
  blitBuffer(srcBuffer, dstCanvas, destX, destY) {
61011
+ tiledperf.start("blitBufferPixels");
60052
61012
  const srcData = srcBuffer.data;
60053
61013
  const dstData = dstCanvas.data;
60054
61014
  const srcW = srcBuffer.width;
@@ -60061,7 +61021,10 @@ class TileLayer extends BaseLayer {
60061
61021
  const endX = Math.min(dstW, destX + srcW);
60062
61022
  const endY = Math.min(dstH, destY + srcH);
60063
61023
 
60064
- if (startX >= endX || startY >= endY) return;
61024
+ if (startX >= endX || startY >= endY) {
61025
+ tiledperf.end("blitBufferPixels");
61026
+ return;
61027
+ }
60065
61028
 
60066
61029
  if (this.assumeOpaque) {
60067
61030
  for (let y = startY; y < endY; y++) {
@@ -60106,12 +61069,136 @@ class TileLayer extends BaseLayer {
60106
61069
  }
60107
61070
  }
60108
61071
  }
60109
-
60110
- dstCanvas.markRegion(startX, startY, endX - startX, endY - startY);
61072
+
61073
+ dstCanvas.markRegion(startX, startY, endX - startX, endY - startY);
61074
+ tiledperf.end("blitBufferPixels");
61075
+ }
61076
+
61077
+
61078
+
61079
+
61080
+ /**
61081
+ * Draw only a specific region of the tilemap
61082
+ * Used for incremental cache updates
61083
+ *
61084
+ * @param {CacheBuffer} canvas - buffer to draw to
61085
+ * @param {Object} camera - fake camera for coordinate conversion
61086
+ * @param {Object} region - {x, y, width, height} in cache coordinates
61087
+ * @param {Object} worldRegion - {x, y, width, height} in world coordinates
61088
+ */
61089
+
61090
+ drawRegion(canvas, camera, region, worldRegion) {
61091
+ tiledperf.start("tilelayer_drawRegion");
61092
+ // Cache locals for faster access
61093
+ const chunks = this.chunkspertiles[0];
61094
+ const chunkSize = this.chunkSize;
61095
+ const tileW = this.tileWidth;
61096
+ const tileH = this.tileHeight;
61097
+ const layerWorldX = this.worldPosition.x;
61098
+ const layerWorldY = this.worldPosition.y;
61099
+ const regionX = worldRegion.x;
61100
+ const regionY = worldRegion.y;
61101
+ const regionW = worldRegion.width;
61102
+ const regionH = worldRegion.height;
61103
+
61104
+ // Calculate chunk bounds - inline to avoid function call
61105
+ const chunkPixelW = chunkSize * tileW;
61106
+ const chunkPixelH = chunkSize * tileH;
61107
+
61108
+ let startChunkX = Math.floor((regionX - layerWorldX) / chunkPixelW);
61109
+ let endChunkX = Math.ceil((regionX + regionW - layerWorldX) / chunkPixelW);
61110
+ let startChunkY = Math.floor((regionY - layerWorldY) / chunkPixelH);
61111
+ let endChunkY = Math.ceil((regionY + regionH - layerWorldY) / chunkPixelH);
61112
+
61113
+ // Clamp to valid chunk range
61114
+ if (startChunkX < 0) startChunkX = 0;
61115
+ if (startChunkY < 0) startChunkY = 0;
61116
+ const maxChunkX = chunks[0].length - 1;
61117
+ const maxChunkY = chunks.length - 1;
61118
+ if (endChunkX > maxChunkX) endChunkX = maxChunkX;
61119
+ if (endChunkY > maxChunkY) endChunkY = maxChunkY;
61120
+
61121
+ // Blit chunks - tight loop, no allocations
61122
+ for (let cy = startChunkY; cy <= endChunkY; cy++) {
61123
+ const chunkRow = this.chunkBuffers[cy];
61124
+
61125
+ for (let cx = startChunkX; cx <= endChunkX; cx++) {
61126
+ const chunkBuffer = chunkRow[cx];
61127
+
61128
+ // Calculate world position - inline
61129
+ const chunkWorldX = layerWorldX + cx * chunkPixelW;
61130
+ const chunkWorldY = layerWorldY + cy * chunkPixelH;
61131
+
61132
+ // Convert to cache coords using the proper worldToScreen method
61133
+ const screenPos = camera.worldToScreen(chunkWorldX, chunkWorldY);
61134
+ const destX = Math.floor(screenPos.x);
61135
+ const destY = Math.floor(screenPos.y);
61136
+
61137
+ // Quick bounds check - inline
61138
+ const chunkW = chunkBuffer.width;
61139
+ const chunkH = chunkBuffer.height;
61140
+ const regionLeft = region.x;
61141
+ const regionRight = region.x + region.width;
61142
+ const regionTop = region.y;
61143
+ const regionBottom = region.y + region.height;
61144
+
61145
+ if (destX + chunkW < regionLeft || destX > regionRight ||
61146
+ destY + chunkH < regionTop || destY > regionBottom) {
61147
+ continue;
61148
+ }
61149
+
61150
+ // Blit chunk
61151
+ this.blitBuffer(chunkBuffer, canvas, destX, destY);
61152
+ }
60111
61153
  }
61154
+ tiledperf.end("tilelayer_drawRegion");
61155
+ }
61156
+ // drawRegion(canvas, camera, region, worldRegion) {
61157
+ // tiledperf.start("drawRegion");
61158
+ // // Calculate which chunks intersect this region
61159
+ // const startChunkX = Math.floor((worldRegion.x - this.worldPosition.x) / (this.chunkSize * this.tileWidth));
61160
+ // const endChunkX = Math.ceil((worldRegion.x + worldRegion.width - this.worldPosition.x) / (this.chunkSize * this.tileWidth));
61161
+ // const startChunkY = Math.floor((worldRegion.y - this.worldPosition.y) / (this.chunkSize * this.tileHeight));
61162
+ // const endChunkY = Math.ceil((worldRegion.y + worldRegion.height - this.worldPosition.y) / (this.chunkSize * this.tileHeight));
61163
+
61164
+ // const chunks = this.chunkspertiles[0];
61165
+ // const clampStartX = Math.max(0, startChunkX);
61166
+ // const clampEndX = Math.min(chunks[0].length - 1, endChunkX);
61167
+ // const clampStartY = Math.max(0, startChunkY);
61168
+ // const clampEndY = Math.min(chunks.length - 1, endChunkY);
61169
+
61170
+ // // Blit only the chunks that intersect the region
61171
+ // for (let cy = clampStartY; cy <= clampEndY; cy++) {
61172
+ // for (let cx = clampStartX; cx <= clampEndX; cx++) {
61173
+ // const chunkBuffer = this.chunkBuffers[cy][cx];
61174
+ // const chunkWorldX = this.worldPosition.x + cx * this.chunkSize * this.tileWidth;
61175
+ // const chunkWorldY = this.worldPosition.y + cy * this.chunkSize * this.tileHeight;
61176
+
61177
+ // // Convert world position to cache coordinates
61178
+ // const screenPos = camera.worldToScreen(chunkWorldX, chunkWorldY);
61179
+ // const destX = Math.floor(screenPos.x);
61180
+ // const destY = Math.floor(screenPos.y);
61181
+
61182
+ // // Only blit if chunk intersects region
61183
+ // if (destX + chunkBuffer.width >= region.x &&
61184
+ // destX < region.x + region.width &&
61185
+ // destY + chunkBuffer.height >= region.y &&
61186
+ // destY < region.y + region.height) {
61187
+
61188
+ // this.blitBuffer(chunkBuffer, canvas, destX, destY);
61189
+ // }
61190
+ // }
61191
+ // }
61192
+ // tiledperf.end("drawRegion");
61193
+ // }
60112
61194
 
60113
- draw(canvas, camera) {
61195
+ draw(canvas, camera, region = null, worldRegion = null) {
61196
+ if (region && worldRegion) {
61197
+ this.drawRegion(canvas, camera, region, worldRegion);
61198
+ return;
61199
+ }
60114
61200
  tiledperf.start("drawVisibleChunks");
61201
+ tilemapProfiler.startFrame(camera);
60115
61202
  const visibleBounds = camera.getVisibleBounds();
60116
61203
  const chunks = this.chunkspertiles[0];
60117
61204
 
@@ -60129,6 +61216,8 @@ class TileLayer extends BaseLayer {
60129
61216
  const screenPos = camera.worldToScreen(worldX, worldY);
60130
61217
  const destX = Math.floor(screenPos.x);
60131
61218
  const destY = Math.floor(screenPos.y);
61219
+ tilemapProfiler.recordBlit(chunkBuffer.width, chunkBuffer.height);
61220
+ tilemapProfiler.recordChunk();
60132
61221
  this.blitBuffer(chunkBuffer, canvas, destX, destY);
60133
61222
  }
60134
61223
  }
@@ -60188,7 +61277,66 @@ class ObjectLayer extends BaseLayer {
60188
61277
  }
60189
61278
  }
60190
61279
 
60191
- draw(canvas, camera) {
61280
+ /**
61281
+ * Draw only objects that intersect the specified region
61282
+ * For incremental cache updates
61283
+ */
61284
+ drawRegion(canvas, camera, cacheRegion, worldRegion) {
61285
+ // Iterate through precomputed render cache
61286
+ // Only draw objects that intersect worldRegion
61287
+ tiledperf.start("objectlayer_drawregion");
61288
+
61289
+
61290
+ const regionLeft = worldRegion.x;
61291
+ const regionRight = worldRegion.x + worldRegion.width;
61292
+ const regionTop = worldRegion.y;
61293
+ const regionBottom = worldRegion.y + worldRegion.height;
61294
+
61295
+ for (let i = 0; i < this.objectRenderCache.length; i++) {
61296
+ const cached = this.objectRenderCache[i];
61297
+
61298
+ // Quick AABB intersection test
61299
+ const objLeft = cached.worldX;
61300
+ const objRight = cached.worldX + cached.width;
61301
+ const objTop = cached.worldY;
61302
+ const objBottom = cached.worldY + cached.height;
61303
+
61304
+ // Skip if no intersection
61305
+ if (objRight < regionLeft || objLeft > regionRight ||
61306
+ objBottom < regionTop || objTop > regionBottom) {
61307
+ continue;
61308
+ }
61309
+
61310
+ // Object intersects region - draw it
61311
+ const screenPos = camera.worldToScreen(cached.worldX, cached.worldY);
61312
+
61313
+ const destRect = {
61314
+ x: Math.floor(screenPos.x),
61315
+ y: Math.floor(screenPos.y),
61316
+ width: Math.floor(cached.width * camera.zoom),
61317
+ height: Math.floor(cached.height * camera.zoom)
61318
+ };
61319
+
61320
+ // Draw to canvas
61321
+ drawAtlasRegionToCanvas(cached.image, cached.srcRect, canvas, destRect, {
61322
+ algorithm: "nn",
61323
+ camera: camera
61324
+ });
61325
+ }
61326
+
61327
+ tiledperf.end("objectlayer_drawregion");
61328
+
61329
+ }
61330
+
61331
+
61332
+ draw(canvas, camera, cacheRegion = null, worldRegion = null) {
61333
+ tiledperf.start("objectlayer_draw");
61334
+
61335
+ if (cacheRegion && worldRegion) {
61336
+ this.drawRegion(canvas, camera, cacheRegion, worldRegion);
61337
+ return;
61338
+ }
61339
+
60192
61340
  for (const cached of this.objectRenderCache) {
60193
61341
  const screenPos = camera.worldToScreen(cached.worldX, cached.worldY);
60194
61342
 
@@ -60209,6 +61357,8 @@ class ObjectLayer extends BaseLayer {
60209
61357
  camera: camera
60210
61358
  });
60211
61359
  }
61360
+ tiledperf.end("objectlayer_draw");
61361
+
60212
61362
  }
60213
61363
 
60214
61364
  printDebug() {
@@ -60408,9 +61558,124 @@ class CollisionLayer extends BaseLayer {
60408
61558
  return created;
60409
61559
  }
60410
61560
 
60411
- draw(canvas, camera) {
61561
+ drawRegion(canvas, camera, cacheRegion, worldRegion) {
61562
+ if (!this.debugCollisions) return;
61563
+
61564
+ const regionLeft = worldRegion.x;
61565
+ const regionRight = worldRegion.x + worldRegion.width;
61566
+ const regionTop = worldRegion.y;
61567
+ const regionBottom = worldRegion.y + worldRegion.height;
61568
+
61569
+ const getColorForBody = (body) => {
61570
+ switch ((body.type || '').toLowerCase()) {
61571
+ case 'static': return { r: 120, g: 120, b: 120, a: 220 };
61572
+ case 'kinematic': return { r: 0, g: 140, b: 255, a: 220 };
61573
+ case 'dynamic':
61574
+ default: return { r: 255, g: 60, b: 60, a: 220 };
61575
+ }
61576
+ };
61577
+
61578
+ // Inline AABB test - no function call overhead
61579
+ const intersectsRegion = (left, top, right, bottom) => {
61580
+ return !(right < regionLeft || left > regionRight ||
61581
+ bottom < regionTop || top > regionBottom);
61582
+ };
61583
+
61584
+ const strokeThickness = 1;
61585
+
61586
+ for (const [name, body] of this.bodies.entries()) {
61587
+ if (!body) continue;
61588
+
61589
+ const color = getColorForBody(body);
61590
+ const children = body.children || [];
61591
+
61592
+ if (!children.length) continue;
61593
+
61594
+ for (let s = 0; s < children.length; s++) {
61595
+ const shape = children[s];
61596
+ if (!shape || !shape.shapeType) continue;
61597
+
61598
+ const opts = shape.options || {};
61599
+ const anchor = shape.anchorOffset || { x: 0, y: 0 };
61600
+ const baseX = (opts.x || 0);
61601
+ const baseY = (opts.y || 0);
61602
+ const lx = baseX - anchor.x;
61603
+ const ly = baseY - anchor.y;
61604
+ const worldBaseX = (body.position && body.position.x !== undefined) ? body.position.x + lx : lx;
61605
+ const worldBaseY = (body.position && body.position.y !== undefined) ? body.position.y + ly : ly;
61606
+
61607
+ // Quick reject based on shape type
61608
+ let shouldDraw = false;
61609
+
61610
+ if (shape.shapeType === 'box') {
61611
+ const w = opts.width || 0;
61612
+ const h = opts.height || 0;
61613
+ const left = worldBaseX - w / 2;
61614
+ const top = worldBaseY - h / 2;
61615
+ shouldDraw = intersectsRegion(left, top, left + w, top + h);
61616
+
61617
+ if (shouldDraw) {
61618
+ const screenPos = camera.worldToScreen(left, top);
61619
+ ShapeDrawer.strokeRect(canvas, screenPos.x, screenPos.y, w, h, strokeThickness, color.r, color.g, color.b, color.a);
61620
+ }
61621
+ } else if (shape.shapeType === 'circle') {
61622
+ const rpx = opts.radius || 0;
61623
+ const left = worldBaseX - rpx;
61624
+ const top = worldBaseY - rpx;
61625
+ shouldDraw = intersectsRegion(left, top, worldBaseX + rpx, worldBaseY + rpx);
61626
+
61627
+ if (shouldDraw) {
61628
+ const screenPos = camera.worldToScreen(worldBaseX, worldBaseY);
61629
+ ShapeDrawer.strokeCircle(canvas, screenPos.x, screenPos.y, rpx, strokeThickness, color.r, color.g, color.b, color.a);
61630
+ }
61631
+ } else if (shape.shapeType === 'polygon' || shape.shapeType === 'poly') {
61632
+ const pts = opts.points || [];
61633
+ if (!pts.length) continue;
61634
+
61635
+ // Calculate AABB for polygon
61636
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
61637
+ for (let p = 0; p < pts.length; p++) {
61638
+ const wx = worldBaseX + pts[p].x;
61639
+ const wy = worldBaseY + pts[p].y;
61640
+ if (wx < minX) minX = wx;
61641
+ if (wy < minY) minY = wy;
61642
+ if (wx > maxX) maxX = wx;
61643
+ if (wy > maxY) maxY = wy;
61644
+ }
61645
+
61646
+ shouldDraw = intersectsRegion(minX, minY, maxX, maxY);
61647
+
61648
+ if (shouldDraw) {
61649
+ const worldPts = pts.map(p => ({ x: worldBaseX + p.x, y: worldBaseY + p.y }));
61650
+ const screenPts = worldPts.map(p => camera.worldToScreen(p.x, p.y));
61651
+
61652
+ if (shape.shapeType === 'polygon' || opts.closed === true) {
61653
+ PolygonDrawer.fillPolygon(canvas, screenPts, color.r, color.g, color.b, Math.floor(color.a * 0.2));
61654
+ PolygonDrawer.strokePolygon(canvas, screenPts, strokeThickness, color.r, color.g, color.b, color.a);
61655
+ } else {
61656
+ PolygonDrawer.strokePolygon(canvas, screenPts, strokeThickness, color.r, color.g, color.b, color.a);
61657
+ }
61658
+ }
61659
+ } else if (shape.shapeType === 'point') {
61660
+ shouldDraw = intersectsRegion(worldBaseX - 3, worldBaseY - 3, worldBaseX + 3, worldBaseY + 3);
61661
+
61662
+ if (shouldDraw) {
61663
+ const screenPos = camera.worldToScreen(worldBaseX, worldBaseY);
61664
+ ShapeDrawer.fillCircle(canvas, screenPos.x, screenPos.y, 3, color.r, color.g, color.b, color.a);
61665
+ }
61666
+ }
61667
+ }
61668
+ }
61669
+ }
61670
+
61671
+ draw(canvas, camera, cacheRegion = null, worldRegion = null) {
60412
61672
  if (!this.debugCollisions) return;
60413
61673
 
61674
+ if (cacheRegion && worldRegion) {
61675
+ this.drawRegion(canvas, camera, cacheRegion, worldRegion);
61676
+ return;
61677
+ }
61678
+
60414
61679
  const getColorForBody = (body) => {
60415
61680
  switch ((body.type || '').toLowerCase()) {
60416
61681
  case 'static': return { r: 120, g: 120, b: 120, a: 220 };
@@ -60625,6 +61890,19 @@ class Tiled extends Node {
60625
61890
  this.opaque = opaque;
60626
61891
  this.renderer = renderer;
60627
61892
 
61893
+ this.useCache = opts.useCache !== false; // default true
61894
+ this.cache = null;
61895
+ this.cacheInitialized = false;
61896
+
61897
+ // Full map pre-render strategy (Strategy 2)
61898
+ this.preRenderEntireMap = opts.preRenderEntireMap || false;
61899
+ this.fullMapBuffer = null;
61900
+
61901
+ // Chunked pre-render strategy (Strategy 3)
61902
+ this.useChunkedRender = opts.useChunkedRender || false;
61903
+ this.chunkedBuffer = null;
61904
+ this.chunkSize = opts.chunkSize || 256;
61905
+
60628
61906
  if (path$1.extname(path) != ".json") {
60629
61907
  throw new Error("expected a json map");
60630
61908
  }
@@ -60645,6 +61923,20 @@ class Tiled extends Node {
60645
61923
  tiledperf.start("processLayers");
60646
61924
  this.processLayers(this.json.layers);
60647
61925
  tiledperf.end("processLayers");
61926
+
61927
+ this._drawMethods = [
61928
+ function fallback(c, cam) {
61929
+ for (let i = 0; i < this.layers.length; i++) {
61930
+ this.layers[i].draw(c, cam);
61931
+ }
61932
+ }.bind(this),
61933
+ function fast(c, cam) {
61934
+ const strategy = this.cache.needsUpdate(cam);
61935
+ console.log("STRAT", strategy);
61936
+ this.cache.copyToDisplay(c, cam);
61937
+ }.bind(this)
61938
+ ];
61939
+
60648
61940
  }
60649
61941
 
60650
61942
  processTilesets(tilesets) {
@@ -60665,12 +61957,191 @@ class Tiled extends Node {
60665
61957
 
60666
61958
 
60667
61959
  this.collisionObjects = c;
60668
-
61960
+
61961
+ }
61962
+
61963
+
61964
+ initializeFullMapBuffer(camera) {
61965
+ if (!this.preRenderEntireMap) return;
61966
+
61967
+ console.log('[Tiled] Initializing full-map pre-render...');
61968
+
61969
+ // Calculate full map bounds in world space
61970
+ let mapWidth = this.json.width * this.json.tilewidth;
61971
+ let mapHeight = this.json.height * this.json.tileheight;
61972
+
61973
+ // Account for layer offsets
61974
+ for (const layer of this.layers) {
61975
+ if (layer.worldPosition) {
61976
+ const layerRightEdge = layer.worldPosition.x + (layer.layer?.width || 0) * this.json.tilewidth;
61977
+ const layerBottomEdge = layer.worldPosition.y + (layer.layer?.height || 0) * this.json.tileheight;
61978
+ mapWidth = Math.max(mapWidth, layerRightEdge);
61979
+ mapHeight = Math.max(mapHeight, layerBottomEdge);
61980
+ }
61981
+ }
61982
+
61983
+ this.fullMapBuffer = new FullMapPreRender(Math.ceil(mapWidth), Math.ceil(mapHeight));
61984
+
61985
+ // Render all layers to the full-map buffer
61986
+ this.fullMapBuffer.render((buffer) => {
61987
+ const fakeCamera = {
61988
+ worldPosition: { x: 0, y: 0 },
61989
+ zoom: 1,
61990
+ worldToScreen: (wx, wy) => ({ x: wx, y: wy }),
61991
+ getVisibleBounds: () => ({
61992
+ left: 0,
61993
+ top: 0,
61994
+ right: mapWidth,
61995
+ bottom: mapHeight,
61996
+ width: mapWidth,
61997
+ height: mapHeight
61998
+ }),
61999
+ cullingEnabled: true,
62000
+ frustum: { left: 0, right: mapWidth, top: 0, bottom: mapHeight }
62001
+ };
62002
+
62003
+ for (let i = 0; i < this.layers.length; i++) {
62004
+ this.layers[i].draw(buffer, fakeCamera);
62005
+ }
62006
+ });
62007
+
62008
+ console.log(`[Tiled] Full-map buffer rendered: ${mapWidth}x${mapHeight}`);
62009
+ }
62010
+
62011
+ initializeChunkedRender(camera) {
62012
+ if (!this.useChunkedRender) return;
62013
+
62014
+ console.log('[Tiled] Initializing chunked pre-render...');
62015
+
62016
+ // Calculate full map bounds in world space
62017
+ let mapWidth = this.json.width * this.json.tilewidth;
62018
+ let mapHeight = this.json.height * this.json.tileheight;
62019
+
62020
+ // Account for layer offsets
62021
+ for (const layer of this.layers) {
62022
+ if (layer.worldPosition) {
62023
+ const layerRightEdge = layer.worldPosition.x + (layer.layer?.width || 0) * this.json.tilewidth;
62024
+ const layerBottomEdge = layer.worldPosition.y + (layer.layer?.height || 0) * this.json.tileheight;
62025
+ mapWidth = Math.max(mapWidth, layerRightEdge);
62026
+ mapHeight = Math.max(mapHeight, layerBottomEdge);
62027
+ }
62028
+ }
62029
+
62030
+ this.chunkedBuffer = new ChunkedPreRender(Math.ceil(mapWidth), Math.ceil(mapHeight), this.chunkSize);
62031
+
62032
+ // Register render callback for all layers
62033
+ this.chunkedBuffer.onRenderChunk((buffer, region) => {
62034
+ const fakeCamera = {
62035
+ worldPosition: {
62036
+ x: region.x + region.width / 2,
62037
+ y: region.y + region.height / 2
62038
+ },
62039
+ zoom: 1,
62040
+ worldToScreen: (wx, wy) => ({
62041
+ x: wx - region.x,
62042
+ y: wy - region.y
62043
+ }),
62044
+ getVisibleBounds: () => ({
62045
+ left: region.x,
62046
+ top: region.y,
62047
+ right: region.x + region.width,
62048
+ bottom: region.y + region.height,
62049
+ width: region.width,
62050
+ height: region.height
62051
+ }),
62052
+ cullingEnabled: true,
62053
+ frustum: {
62054
+ left: region.x,
62055
+ right: region.x + region.width,
62056
+ top: region.y,
62057
+ bottom: region.y + region.height
62058
+ }
62059
+ };
62060
+
62061
+ for (let i = 0; i < this.layers.length; i++) {
62062
+ this.layers[i].draw(buffer, fakeCamera);
62063
+ }
62064
+ });
62065
+
62066
+ // Pre-render all chunks
62067
+ this.chunkedBuffer.renderAllChunks();
62068
+ console.log(`[Tiled] Chunked render initialized: ${mapWidth}x${mapHeight} into ${this.chunkSize}x${this.chunkSize} chunks`);
62069
+ }
62070
+
62071
+ initializeCache(viewportWidth, viewportHeight, camera) {
62072
+ if (!this.useCache) return;
62073
+
62074
+ console.log('[Tiled] Initializing offscreen cache...');
62075
+
62076
+ // Create cache
62077
+ this.cache = new OffscreenTilemapCache(viewportWidth, viewportHeight);
62078
+
62079
+ // Set cache to cover camera view with margin
62080
+ const bounds = camera.getVisibleBounds();
62081
+
62082
+ // Center cache on camera
62083
+ // this.cache.setWorldRegion(
62084
+ // camera.worldPosition.x - halfViewW - this.cache.marginPixels.x,
62085
+ // camera.worldPosition.y - halfViewH - this.cache.marginPixels.y
62086
+ // );
62087
+ this.cache.setWorldRegion(bounds.left, bounds.top);
62088
+
62089
+
62090
+ // Render all layers to cache
62091
+ this._renderToCache();
62092
+
62093
+ this.cacheInitialized = true;
62094
+ this.cache.fullRedraws++;
62095
+
62096
+ console.log('[Tiled] Cache initialized and rendered');
62097
+ }
62098
+
62099
+
62100
+ _renderToCache() {
62101
+ tiledperf.start("_renderToCache");
62102
+ if (!this.cache) {
62103
+ tiledperf.end("_renderToCache");
62104
+ return;
62105
+ }
62106
+
62107
+
62108
+ this.cache.clear();
62109
+
62110
+ const fakeCamera = {
62111
+ worldPosition: {
62112
+ x: this.cache.worldX + this.cache.cacheWidth / 2,
62113
+ y: this.cache.worldY + this.cache.cacheHeight / 2
62114
+ },
62115
+ zoom: 1,
62116
+ worldToScreen: (wx, wy) => {
62117
+ return {
62118
+ x: wx - this.cache.worldX,
62119
+ y: wy - this.cache.worldY
62120
+ };
62121
+ },
62122
+ getVisibleBounds: () => this.cache.getWorldBounds(),
62123
+ cullingEnabled: true,
62124
+ frustum: {
62125
+ left: this.cache.worldX,
62126
+ right: this.cache.worldX + this.cache.cacheWidth,
62127
+ top: this.cache.worldY,
62128
+ bottom: this.cache.worldY + this.cache.cacheHeight
62129
+ }
62130
+ };
62131
+
62132
+ // draw all layers to cache buffer
62133
+ for (let i = 0; i < this.layers.length; i++) {
62134
+ this.layers[i].draw(this.cache.buffer, fakeCamera);
62135
+ }
62136
+ tiledperf.end("_renderToCache");
60669
62137
  }
62138
+
62139
+
62140
+
60670
62141
  _ready() {
60671
62142
  for (let i = 0; i < this.collisionObjects.length; i++)
60672
62143
  this.parent.addChild(this.collisionObjects[i]);
60673
- console.log("added ", this.collisionObjects.length, " bodies");
62144
+ console.log("added ", this.collisionObjects.length, " bodies");
60674
62145
  }
60675
62146
  processLayers(layers) {
60676
62147
  for (let i = 0; i < layers.length; i++) {
@@ -60701,11 +62172,203 @@ class Tiled extends Node {
60701
62172
  }
60702
62173
  }
60703
62174
 
60704
- _draw(canvas, camera) {
60705
- // polymorphic draw - each layer handles its own rendering
62175
+
62176
+
62177
+ _renderRegionToCache(cacheRegion, worldRegion) {
62178
+ tiledperf.start("_renderRegionToCache");
62179
+ if (!this.cache) {
62180
+ tiledperf.end("_renderRegionToCache");
62181
+ return;
62182
+ }
62183
+
62184
+ // Create fake camera for this region
62185
+ const fakeCamera = {
62186
+ worldPosition: {
62187
+ x: this.cache.worldX + this.cache.cacheWidth / 2,
62188
+ y: this.cache.worldY + this.cache.cacheHeight / 2
62189
+ },
62190
+ zoom: 1,
62191
+ worldToScreen: (wx, wy) => {
62192
+ return {
62193
+ x: wx - this.cache.worldX,
62194
+ y: wy - this.cache.worldY
62195
+ };
62196
+ },
62197
+ getVisibleBounds: () => ({
62198
+ left: worldRegion.x,
62199
+ top: worldRegion.y,
62200
+ right: worldRegion.x + worldRegion.width,
62201
+ bottom: worldRegion.y + worldRegion.height,
62202
+ width: worldRegion.width,
62203
+ height: worldRegion.height
62204
+ }),
62205
+ cullingEnabled: true,
62206
+ frustum: {
62207
+ left: worldRegion.x,
62208
+ right: worldRegion.x + worldRegion.width,
62209
+ top: worldRegion.y,
62210
+ bottom: worldRegion.y + worldRegion.height
62211
+ }
62212
+ };
62213
+
62214
+ // Draw each layer's region
60706
62215
  for (let i = 0; i < this.layers.length; i++) {
60707
- this.layers[i].draw(canvas, camera);
62216
+ this.layers[i].draw(this.cache.buffer, fakeCamera, cacheRegion, worldRegion);
62217
+ }
62218
+ tiledperf.end("_renderRegionToCache");
62219
+ }
62220
+
62221
+ _draw(canvas, camera) {
62222
+ tiledperf.start("_draw");
62223
+
62224
+ // Strategy 3: Chunked pre-render
62225
+ if (this.useChunkedRender && !this.chunkedBuffer) {
62226
+ this.initializeChunkedRender(camera);
62227
+ }
62228
+
62229
+ if (this.useChunkedRender && this.chunkedBuffer) {
62230
+ tiledperf.start("chunkedBuffer_copy");
62231
+ this.chunkedBuffer.copyToDisplay(canvas, camera);
62232
+ tiledperf.end("chunkedBuffer_copy");
62233
+ tiledperf.end("_draw");
62234
+ return;
62235
+ }
62236
+
62237
+ // Strategy 2: Full-map pre-render
62238
+ if (this.preRenderEntireMap && !this.fullMapBuffer) {
62239
+ this.initializeFullMapBuffer(camera);
62240
+ }
62241
+
62242
+ if (this.preRenderEntireMap && this.fullMapBuffer) {
62243
+ tiledperf.start("fullMapBuffer_copy");
62244
+ this.fullMapBuffer.copyToDisplay(canvas, camera);
62245
+ tiledperf.end("fullMapBuffer_copy");
62246
+ tiledperf.end("_draw");
62247
+ return;
62248
+ }
62249
+
62250
+ // Strategy 1: Incremental offscreen cache
62251
+ // First frame: initialize
62252
+ if (this.useCache && !this.cacheInitialized) {
62253
+ this.initializeCache(canvas.width, canvas.height, camera);
62254
+ }
62255
+
62256
+ if (this.useCache && this.cache) {
62257
+ // Determine what kind of update we need
62258
+ const strategy = this.cache.needsUpdate(camera);
62259
+
62260
+ switch (strategy.type) {
62261
+ case 'NONE':
62262
+ tiledperf.start("cacheUpdate_NONE");
62263
+ // Camera didn't move - just copy from cache
62264
+ this.cache.skippedUpdates++;
62265
+ tiledperf.end("cacheUpdate_NONE");
62266
+ break;
62267
+
62268
+ case 'INCREMENTAL':
62269
+ tiledperf.start("cacheUpdate_INCREMENTAL");
62270
+ // Small move - incremental update (Chapter 4 will implement this)
62271
+ // For now, treat as full redraw
62272
+ // console.log(`[Tiled] Incremental update needed (${strategy.reason}), delta: ${strategy.delta.x.toFixed(1)}, ${strategy.delta.y.toFixed(1)}`);
62273
+ // this._fullCacheRedraw(camera);
62274
+ this.cache.incrementalUpdates++;
62275
+ this.cache.incrementalUpdate(
62276
+ strategy.delta,
62277
+ (cacheRegion, worldRegion) => {
62278
+ this._renderRegionToCache(cacheRegion, worldRegion);
62279
+ }
62280
+ );
62281
+ tiledperf.end("cacheUpdate_INCREMENTAL");
62282
+ break;
62283
+
62284
+ case 'FULL_REDRAW':
62285
+ tiledperf.start("cacheUpdate_FULL_REDRAW");
62286
+ // Teleport or zoom - need full redraw
62287
+ console.log(`[Tiled] Full redraw needed (${strategy.reason})`);
62288
+ this._fullCacheRedraw(camera);
62289
+ this.cache.fullRedraws++;
62290
+ tiledperf.end("cacheUpdate_FULL_REDRAW");
62291
+ break;
62292
+ }
62293
+
62294
+ // Always copy cache to display
62295
+ tiledperf.start("copyToDisplay");
62296
+ this.cache.copyToDisplay(canvas, camera);
62297
+ tiledperf.end("copyToDisplay");
62298
+
62299
+ // Commit frame
62300
+ this.cache.commitFrame();
62301
+
62302
+ } else {
62303
+ // Fallback: no cache
62304
+ tiledperf.start("directLayerDraw");
62305
+ for (let i = 0; i < this.layers.length; i++) {
62306
+ this.layers[i].draw(canvas, camera);
62307
+ }
62308
+ tiledperf.end("directLayerDraw");
60708
62309
  }
62310
+ tiledperf.end("_draw");
62311
+ }
62312
+
62313
+ // Full cache redraw - recenter and render
62314
+ _fullCacheRedraw(camera) {
62315
+ tiledperf.start("_fullCacheRedraw");
62316
+ this.cache.viewportWidth / 2;
62317
+ this.cache.viewportHeight / 2;
62318
+
62319
+ const bounds = camera.getVisibleBounds();
62320
+ // Recenter cache on camera
62321
+ // this.cache.setWorldRegion(
62322
+ // camera.worldPosition.x - halfViewW - this.cache.marginPixels.x,
62323
+ // camera.worldPosition.y - halfViewH - this.cache.marginPixels.y
62324
+ // );
62325
+
62326
+ this.cache.setWorldRegion(bounds.left, bounds.top);
62327
+
62328
+ this._renderToCache();
62329
+ tiledperf.end("_fullCacheRedraw");
62330
+ }
62331
+
62332
+
62333
+ // _draw(canvas, camera) {
62334
+ // (this.useCache && !this.cacheInitialized) && (
62335
+ // this.initializeCache(canvas.width, canvas.height, camera),
62336
+ // this.cache.printStats(),
62337
+ // this._drawMethods[1] = function fast(c, cam) { this.cache.copyToDisplay(c, cam); }.bind(this) // update after init
62338
+ // );
62339
+
62340
+ // const useFast = +(this.useCache && !!this.cache);
62341
+ // this._drawMethods[useFast](canvas, camera);
62342
+ // }
62343
+ // _draw(canvas, camera) {
62344
+ // if (this.useCache && !this.cacheInitialized) {
62345
+ // this.initializeCache(canvas.width, canvas.height, camera);
62346
+ // this.cache.printStats()
62347
+ // }
62348
+
62349
+ // if (this.useCache && this.cache) {
62350
+ // // Fast path: copy from cache
62351
+ // this.cache.copyToDisplay(canvas, camera);
62352
+ // } else {
62353
+ // // Fallback: direct render (old way)
62354
+ // // polymorphic draw - each layer handles its own rendering
62355
+ // for (let i = 0; i < this.layers.length; i++) {
62356
+ // this.layers[i].draw(canvas, camera);
62357
+ // }
62358
+ // }
62359
+
62360
+ // }
62361
+
62362
+ needsCacheUpdate(camera) {
62363
+ if (!this.cache) return false;
62364
+
62365
+ const bounds = this.cache.getWorldBounds();
62366
+ const camX = camera.worldPosition.x;
62367
+ const camY = camera.worldPosition.y;
62368
+
62369
+ // Simple check: is camera center still inside cache bounds?
62370
+ return camX < bounds.left || camX > bounds.right ||
62371
+ camY < bounds.top || camY > bounds.bottom;
60709
62372
  }
60710
62373
 
60711
62374
  }