Enhance WorkerCanvas2DRenderer with terrain handling improvements

- Added support for terrain base RGBA values to optimize rendering.
- Refactored tile marking logic to utilize map dimensions for improved accuracy.
- Updated view size handling to prevent unnecessary canvas resizing.
- Introduced methods to rebuild terrain base and refresh terrain for better visual fidelity.
- Cleaned up initialization and cleanup processes for better resource management.
This commit is contained in:
scamiv
2026-02-02 01:50:58 +01:00
parent 636fe2e68a
commit 1ff0b4ddee
+204 -53
View File
@@ -1,4 +1,6 @@
import { Theme } from "../configuration/Config";
import { PastelTheme } from "../configuration/PastelTheme";
import { PastelThemeDark } from "../configuration/PastelThemeDark";
import { TileRef } from "../game/GameMap";
import { TerrainMapData } from "../game/TerrainMapLoader";
import { GameRunner } from "../GameRunner";
@@ -14,12 +16,15 @@ export class WorkerCanvas2DRenderer {
private rasterCanvas: OffscreenCanvas | null = null;
private rasterCtx: Offscreen2D | null = null;
private rasterImage: ImageData | null = null;
private terrainBaseRgba: Uint8Array | null = null;
private gameViewAdapter: GameViewAdapter | null = null;
private gameRunner: GameRunner | null = null;
private theme: Theme | null = null;
private ready = false;
private mapWidth = 1;
private mapHeight = 1;
private viewScale = 1;
private viewOffsetX = 0;
@@ -58,6 +63,11 @@ export class WorkerCanvas2DRenderer {
this.gameRunner = gameRunner;
this.theme = theme;
const mapW = gameRunner.game.width();
const mapH = gameRunner.game.height();
this.mapWidth = mapW;
this.mapHeight = mapH;
this.gameViewAdapter = new GameViewAdapter(
gameRunner.game,
mapData,
@@ -66,8 +76,6 @@ export class WorkerCanvas2DRenderer {
cosmeticsByClientID,
);
const mapW = gameRunner.game.width();
const mapH = gameRunner.game.height();
this.rasterCanvas = new OffscreenCanvas(mapW, mapH);
this.rasterCtx = this.rasterCanvas.getContext("2d", {
alpha: true,
@@ -94,6 +102,7 @@ export class WorkerCanvas2DRenderer {
// First paint.
this.rebuildPaletteFromGame();
this.rebuildTerrainBase();
this.markAllDirty();
this.tick();
}
@@ -105,9 +114,12 @@ export class WorkerCanvas2DRenderer {
this.rasterCanvas = null;
this.rasterCtx = null;
this.rasterImage = null;
this.terrainBaseRgba = null;
this.gameViewAdapter = null;
this.gameRunner = null;
this.theme = null;
this.mapWidth = 1;
this.mapHeight = 1;
this.dirtyChunkFlags = new Uint8Array(0);
this.dirtyChunkQueue = new Uint32Array(0);
this.dirtyHead = 0;
@@ -117,8 +129,13 @@ export class WorkerCanvas2DRenderer {
setViewSize(width: number, height: number): void {
if (!this.canvas) return;
this.canvas.width = Math.max(1, Math.floor(width));
this.canvas.height = Math.max(1, Math.floor(height));
const nextWidth = Math.max(1, Math.floor(width));
const nextHeight = Math.max(1, Math.floor(height));
if (this.canvas.width === nextWidth && this.canvas.height === nextHeight) {
return;
}
this.canvas.width = nextWidth;
this.canvas.height = nextHeight;
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
@@ -160,13 +177,15 @@ export class WorkerCanvas2DRenderer {
}
refreshTerrain(): void {
this.rebuildTerrainBase();
this.markAllDirty();
}
markTile(tile: TileRef): void {
if (!this.ready || !this.gameRunner) return;
const x = this.gameRunner.game.x(tile);
const y = this.gameRunner.game.y(tile);
if (!this.ready) return;
// TileRef is a linear index (y * width + x).
const x = tile % this.mapWidth;
const y = (tile / this.mapWidth) | 0;
this.markChunkAt(x, y);
}
@@ -188,14 +207,29 @@ export class WorkerCanvas2DRenderer {
!this.theme ||
!this.gameViewAdapter ||
!this.rasterCtx ||
!this.rasterImage
!this.rasterImage ||
!this.terrainBaseRgba
) {
return;
}
const mapW = this.gameRunner.game.width();
const mapH = this.gameRunner.game.height();
const data = this.rasterImage.data;
const mapW = this.mapWidth;
const mapH = this.mapHeight;
const out = this.rasterImage.data;
const base = this.terrainBaseRgba;
const state = this.gameRunner.game.tileStateView();
const row0 = this.paletteRow0;
const maxSmallId = this.paletteMaxSmallId;
const falloutR = row0[0] ?? 120;
const falloutG = row0[1] ?? 255;
const falloutB = row0[2] ?? 71;
const ownerMask = 0xfff;
const falloutBit = 0x2000;
const mix65 = (a: number, b: number): number =>
((a * 35 + b * 65 + 50) / 100) | 0;
const mix50 = (a: number, b: number): number => (a + b + 1) >> 1;
const budgetMs = 6;
const start = performance.now();
@@ -219,52 +253,59 @@ export class WorkerCanvas2DRenderer {
for (let y = sy; y < ey; y++) {
const row = y * mapW;
for (let x = sx; x < ex; x++) {
const tile = this.gameRunner.game.ref(x, y);
const tile = row + x;
const s = state[tile];
const owner = s & ownerMask;
const hasFallout = (s & falloutBit) !== 0;
let r = 0,
g = 0,
b = 0,
a = 255;
const p = tile * 4;
const tr = base[p];
const tg = base[p + 1];
const tb = base[p + 2];
if (this.gameRunner.game.hasFallout(tile)) {
const idx = 0;
r = this.paletteRow0[idx] ?? 120;
g = this.paletteRow0[idx + 1] ?? 255;
b = this.paletteRow0[idx + 2] ?? 71;
} else if (this.gameRunner.game.hasOwner(tile)) {
const ownerSmallId = this.gameRunner.game.ownerID(tile);
const slot = 10 + Math.max(0, ownerSmallId);
const idx = slot * 4;
if (idx + 2 < this.paletteRow0.length) {
r = this.paletteRow0[idx];
g = this.paletteRow0[idx + 1];
b = this.paletteRow0[idx + 2];
} else {
const rgba = this.theme.terrainColor(
this.gameRunner.game,
tile,
).rgba;
r = rgba.r;
g = rgba.g;
b = rgba.b;
a = rgba.a ?? 255;
}
} else {
const rgba = this.theme.terrainColor(
this.gameRunner.game,
tile,
).rgba;
r = rgba.r;
g = rgba.g;
b = rgba.b;
a = rgba.a ?? 255;
// Fast path: terrain only.
if (owner === 0 && !hasFallout) {
out[p] = tr;
out[p + 1] = tg;
out[p + 2] = tb;
out[p + 3] = 255;
continue;
}
const p = (row + x) * 4;
data[p] = r;
data[p + 1] = g;
data[p + 2] = b;
data[p + 3] = a;
let r = tr;
let g = tg;
let b = tb;
if (owner !== 0) {
// Player colors start at slot 10.
if (owner <= maxSmallId) {
const idx = (10 + owner) * 4;
if (idx + 2 < row0.length) {
let pr = row0[idx];
let pg = row0[idx + 1];
let pb = row0[idx + 2];
if (hasFallout) {
pr = mix50(pr, falloutR);
pg = mix50(pg, falloutG);
pb = mix50(pb, falloutB);
}
r = mix65(tr, pr);
g = mix65(tg, pg);
b = mix65(tb, pb);
}
}
} else if (hasFallout) {
r = mix50(tr, falloutR);
g = mix50(tg, falloutG);
b = mix50(tb, falloutB);
}
out[p] = r;
out[p + 1] = g;
out[p + 2] = b;
out[p + 3] = 255;
}
}
@@ -378,4 +419,114 @@ export class WorkerCanvas2DRenderer {
this.paletteRow1 = row1;
this.hasExternalPalette = false;
}
private rebuildTerrainBase(): void {
if (!this.gameRunner || !this.theme || !this.rasterImage) {
return;
}
const mapW = this.mapWidth;
const mapH = this.mapHeight;
const numTiles = mapW * mapH;
const terrain = this.gameRunner.game.terrainDataView();
const base = new Uint8Array(numTiles * 4);
const isDark = this.theme instanceof PastelThemeDark;
const isPastel =
this.theme instanceof PastelTheme ||
this.theme instanceof PastelThemeDark;
if (isPastel) {
// Decode terrain directly from packed terrain bytes (fast, no allocations).
const shoreR = isDark ? 134 : 204;
const shoreG = isDark ? 133 : 203;
const shoreB = isDark ? 88 : 158;
const shorelineWaterR = isDark ? 50 : 100;
const shorelineWaterG = isDark ? 50 : 143;
const shorelineWaterB = isDark ? 50 : 255;
const waterBaseR = isDark ? 14 : 70;
const waterBaseG = isDark ? 11 : 132;
const waterBaseB = isDark ? 30 : 180;
for (let t = 0; t < numTiles; t++) {
const b = terrain[t];
const isLand = (b & 0x80) !== 0;
const isShoreline = (b & 0x40) !== 0;
const mag = b & 0x1f;
let r = 0,
g = 0,
bb = 0;
if (isLand && isShoreline) {
r = shoreR;
g = shoreG;
bb = shoreB;
} else if (!isLand) {
// Water (ocean + lake share the same formula here).
if (isShoreline) {
r = shorelineWaterR;
g = shorelineWaterG;
bb = shorelineWaterB;
} else if (isDark) {
if (mag < 10) {
const adj = 9 - mag;
r = Math.max(waterBaseR + adj, 0);
g = Math.max(waterBaseG + adj, 0);
bb = Math.max(waterBaseB + adj, 0);
} else {
r = waterBaseR;
g = waterBaseG;
bb = waterBaseB;
}
} else {
const m = mag < 10 ? mag : 10;
const adj = 1 - m;
r = Math.max(waterBaseR + adj, 0);
g = Math.max(waterBaseG + adj, 0);
bb = Math.max(waterBaseB + adj, 0);
}
} else {
// Land (non-shore)
if (mag < 10) {
r = isDark ? 140 : 190;
g = (isDark ? 170 : 220) - 2 * mag;
bb = isDark ? 88 : 138;
} else if (mag < 20) {
r = (isDark ? 150 : 200) + 2 * mag;
g = (isDark ? 133 : 183) + 2 * mag;
bb = (isDark ? 88 : 138) + 2 * mag;
} else {
const half = mag >> 1;
r = (isDark ? 180 : 230) + half;
g = (isDark ? 180 : 230) + half;
bb = (isDark ? 180 : 230) + half;
}
}
const p = t * 4;
base[p] = r;
base[p + 1] = g;
base[p + 2] = bb;
base[p + 3] = 255;
}
} else {
// Fallback for other themes: call the theme once per tile (slow but only on init/theme change).
for (let t = 0; t < numTiles; t++) {
const rgba = this.theme.terrainColor(
this.gameRunner.game,
t as TileRef,
).rgba;
const p = t * 4;
base[p] = rgba.r;
base[p + 1] = rgba.g;
base[p + 2] = rgba.b;
base[p + 3] = rgba.a ?? 255;
}
}
this.terrainBaseRgba = base;
}
}