From 5f0fb81758e687055e09d05589584774218f805d Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 27 May 2026 16:11:00 +0200 Subject: [PATCH] Fix NameLayer atlas asset references --- src/client/graphics/layers/UnitLayer.ts | 22 +++-- src/server/PublicAssetManifest.ts | 52 ++++++++++++ tests/server/PublicAssetManifest.test.ts | 103 +++++++++++++++++++++++ 3 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 27d34fa69..5734dfde8 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -52,6 +52,7 @@ const DYNAMIC_MOVER_ZOOM_HYSTERESIS = 0.2; const DYNAMIC_MOVER_SCALE_SETTLE_MS = 160; const DYNAMIC_MOVER_SCALE_COOLDOWN_MS = 300; const DYNAMIC_MOVER_SUBPIXEL_SNAP = false; +const SPRITELESS_CELL_MARKER_SIZE = 3; const SMALL_SHIP_MASK_SIZE = 5; const TRANSPORT_SHIP_MASK = [ "..B..", @@ -2191,14 +2192,15 @@ export class UnitLayer implements Layer { customTerritoryColor?: Colord, ): MoverSpriteRect { if (this.isSpriteLessCellUnit(unit)) { - const outX = roundCoords ? Math.round(x) : x; - const outY = roundCoords ? Math.round(y) : y; + const markerOffset = Math.floor(SPRITELESS_CELL_MARKER_SIZE / 2); + const outX = (roundCoords ? Math.round(x) : x) - markerOffset; + const outY = (roundCoords ? Math.round(y) : y) - markerOffset; const pad = 2; return { x: outX - pad, y: outY - pad, - w: 1 + pad * 2, - h: 1 + pad * 2, + w: SPRITELESS_CELL_MARKER_SIZE + pad * 2, + h: SPRITELESS_CELL_MARKER_SIZE + pad * 2, }; } @@ -2248,10 +2250,16 @@ export class UnitLayer implements Layer { } if (this.isSpriteLessCellUnit(unit)) { - const outX = roundCoords ? Math.round(x) : x; - const outY = roundCoords ? Math.round(y) : y; + const markerOffset = Math.floor(SPRITELESS_CELL_MARKER_SIZE / 2); + const outX = (roundCoords ? Math.round(x) : x) - markerOffset; + const outY = (roundCoords ? Math.round(y) : y) - markerOffset; ctx.fillStyle = this.cellUnitFillStyle(unit); - ctx.fillRect(outX, outY, 1, 1); + ctx.fillRect( + outX, + outY, + SPRITELESS_CELL_MARKER_SIZE, + SPRITELESS_CELL_MARKER_SIZE, + ); ctx.restore(); return this.computeSpriteRect( diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index 0fc31b6a4..cd3ee4bda 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -195,11 +195,63 @@ function renderBitmapFontAsset({ ); } +function renderTextureAtlasJsonAsset({ + resourcesDir, + relativePath, + assetManifest, +}: DerivedPublicAssetRenderContext): string { + const atlas = JSON.parse(readPublicAssetText(resourcesDir, relativePath)) as { + meta?: { image?: unknown }; + }; + + const imagePath = atlas.meta?.image; + if (imagePath === undefined) { + return `${JSON.stringify(atlas, null, 2)}\n`; + } + + if (typeof imagePath !== "string") { + throw new Error( + `Derived asset ${relativePath} contains a non-string atlas image reference`, + ); + } + + if (imagePath.trim().length === 0) { + throw new Error( + `Derived asset ${relativePath} contains a blank atlas image reference`, + ); + } + + if (!isExternalAssetReference(imagePath)) { + const referencedAssetPath = resolveDerivedAssetReference( + relativePath, + imagePath, + ); + const referencedHashedUrl = assetManifest[referencedAssetPath]; + if (!referencedHashedUrl) { + throw new Error( + `Derived asset ${relativePath} references ${referencedAssetPath}, but it is missing from the asset manifest`, + ); + } + + atlas.meta!.image = getEmittedAssetRelativePath( + relativePath, + referencedHashedUrl, + ); + } + + return `${JSON.stringify(atlas, null, 2)}\n`; +} + const DERIVED_PUBLIC_ASSET_RENDERERS: DerivedPublicAssetRenderer[] = [ { matches: (relativePath) => relativePath === "manifest.json", render: renderWebManifestAsset, }, + { + matches: (relativePath) => + relativePath.startsWith("images/") && relativePath.endsWith(".json"), + render: renderTextureAtlasJsonAsset, + }, { matches: (relativePath) => relativePath.startsWith("fonts/") && relativePath.endsWith(".xml"), diff --git a/tests/server/PublicAssetManifest.test.ts b/tests/server/PublicAssetManifest.test.ts index db7ec0620..14ee417de 100644 --- a/tests/server/PublicAssetManifest.test.ts +++ b/tests/server/PublicAssetManifest.test.ts @@ -59,6 +59,36 @@ describe("PublicAssetManifest", () => { await fs.writeFile(pagePath, pageContent); } + async function writeTextureAtlasFixture( + resourcesDir: string, + jsonRelativePath: string, + imageFilePath: string, + imageContent: string = "png-v1", + ): Promise { + const jsonPath = path.join(resourcesDir, jsonRelativePath); + const imagePath = path.join(path.dirname(jsonPath), imageFilePath); + const atlasImagePath = imageFilePath.split(path.sep).join(path.posix.sep); + + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.writeFile( + jsonPath, + JSON.stringify( + { + frames: {}, + meta: { + image: atlasImagePath, + format: "RGBA8888", + size: { w: 1, h: 1 }, + scale: "1", + }, + }, + null, + 2, + ), + ); + await fs.writeFile(imagePath, imageContent); + } + async function emitHashedAsset( outDir: string, assetHref: string, @@ -189,6 +219,79 @@ describe("PublicAssetManifest", () => { expect(emittedManifest).toContain("data:image/png;base64,AAA"); }); + test("rewrites TexturePacker atlas image refs to hashed relative paths", async () => { + const { resourcesDir, outDir } = await createTempResources(); + + await writeTextureAtlasFixture( + resourcesDir, + path.join("images", "namelayer-icons.json"), + "namelayer-icons.png", + ); + + const assetManifest = buildPublicAssetManifest([resourcesDir]); + createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest); + + const jsonHref = assetManifest["images/namelayer-icons.json"]; + const pngHref = assetManifest["images/namelayer-icons.png"]; + const emittedJson = await emitHashedAsset(outDir, jsonHref); + const emittedAtlas = JSON.parse(emittedJson) as { + meta: { image: string }; + }; + + expect(emittedAtlas.meta.image).toBe( + getExpectedRelativeEmittedPath(jsonHref, pngHref), + ); + expect(emittedAtlas.meta.image).not.toBe("namelayer-icons.png"); + }); + + test("TexturePacker atlas JSON hash changes when its image changes", async () => { + const { resourcesDir } = await createTempResources(); + + await writeTextureAtlasFixture( + resourcesDir, + path.join("images", "namelayer-icons.json"), + "namelayer-icons.png", + ); + + const firstManifest = buildPublicAssetManifest([resourcesDir]); + + await fs.writeFile( + path.join(resourcesDir, "images", "namelayer-icons.png"), + "png-v2", + ); + clearPublicAssetManifestCache(); + + const secondManifest = buildPublicAssetManifest([resourcesDir]); + + expect(firstManifest["images/namelayer-icons.png"]).not.toBe( + secondManifest["images/namelayer-icons.png"], + ); + expect(firstManifest["images/namelayer-icons.json"]).not.toBe( + secondManifest["images/namelayer-icons.json"], + ); + }); + + test("fails when TexturePacker atlas JSON references a missing image", async () => { + const { resourcesDir } = await createTempResources(); + + await fs.mkdir(path.join(resourcesDir, "images"), { recursive: true }); + await fs.writeFile( + path.join(resourcesDir, "images", "namelayer-icons.json"), + JSON.stringify( + { + frames: {}, + meta: { image: "missing.png" }, + }, + null, + 2, + ), + ); + + expect(() => buildPublicAssetManifest([resourcesDir])).toThrow( + /images\/namelayer-icons\.json references images\/missing\.png/i, + ); + }); + test("rewrites BMFont XML page filenames to hashed relative paths", async () => { const { resourcesDir, outDir } = await createTempResources();