← Files ModRetro Chromatic PluginARCHIVED FILE

dist/tilemap-compose.js

13.9 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

import { createHash } from "node:crypto";
import { PNG } from "pngjs";
import { GameStudioProjectError } from "./project.js";
import { MAX_EDIT_CELLS, MAX_ROOM_CELLS, TILEMAP_TILE_SIZE, } from "./tilemap-schema.js";
const SOURCE_COLORS = new Set(["071821", "306850", "86c06c", "e0f8cf"]);
export function tilemapError(code, message) {
    throw new GameStudioProjectError(code, message);
}
export function sha256(bytes) {
    return createHash("sha256").update(bytes).digest("hex");
}
export function tilePixels(image, tileX, tileY) {
    const tile = Buffer.alloc(8 * 8 * 4);
    for (let row = 0; row < 8; row++) {
        const offset = ((tileY * 8 + row) * image.width + tileX * 8) * 4;
        image.data.copy(tile, row * 8 * 4, offset, offset + 8 * 4);
    }
    return tile;
}
/** Opaque fixed-shade pixels are data; sparse motif holes are represented only by null cells. */
export function prepareAtlasPixels(atlas, image) {
    if (image.width % 8 !== 0 || image.height % 8 !== 0) {
        tilemapError("INVALID_TILEMAP_ATLAS", "An atlas must be aligned to whole 8 × 8 source tiles.");
    }
    for (let index = 0; index < image.data.length; index += 4) {
        if (image.data[index + 3] !== 255 || !SOURCE_COLORS.has(image.data.subarray(index, index + 3).toString("hex"))) {
            tilemapError("INVALID_TILEMAP_ATLAS", "Atlas PNGs must use only the four exact opaque native game background source shades.");
        }
    }
    const tiles = new Map();
    const patterns = new Map();
    for (const tile of atlas.tiles) {
        if (tiles.has(tile.id))
            tilemapError("DUPLICATE_TILE_ID", `Duplicate source tile ID ${tile.id}.`);
        if (tile.x >= image.width / 8 || tile.y >= image.height / 8) {
            tilemapError("TILE_OUT_OF_BOUNDS", `Source tile ${tile.id} lies outside the atlas.`);
        }
        const pixels = tilePixels(image, tile.x, tile.y);
        tiles.set(tile.id, pixels);
        patterns.set(tile.id, sha256(pixels));
    }
    const primitiveIds = new Set();
    let primitiveCells = 0;
    for (const primitive of atlas.primitives) {
        if (primitiveIds.has(primitive.id))
            tilemapError("DUPLICATE_PRIMITIVE_ID", `Duplicate primitive ID ${primitive.id}.`);
        primitiveIds.add(primitive.id);
        primitiveCells += primitive.cells.length;
        for (const cell of primitive.cells) {
            if (cell !== null && !tiles.has(cell.tileId))
                tilemapError("TILE_NOT_FOUND", `Primitive ${primitive.id} references missing tile ${cell.tileId}.`);
        }
    }
    if (primitiveCells > 32_768)
        tilemapError("TILEMAP_LIMIT", "Atlas motifs exceed 32,768 explicitly described cells.");
    return { tiles, patterns };
}
export function validateRoomDimensions(width, height) {
    if (!Number.isInteger(width) || !Number.isInteger(height) || width < 20 || height < 18 ||
        width > 255 || height > 255 || width * height > MAX_ROOM_CELLS) {
        tilemapError("INVALID_BACKGROUND_DIMENSIONS", "Room dimensions must satisfy native 160 × 144 minimum, 2040 per-axis maximum and 1,048,320 pixel area limits; dimensions are never resized.");
    }
}
export function validateRoomCells(room, atlas) {
    validateRoomDimensions(room.width, room.height);
    if (room.cells.length !== room.width * room.height)
        tilemapError("INVALID_TILEMAP_RECIPE", "Room cell count differs from its dimensions.");
    for (const cell of room.cells) {
        if (!atlas.tiles.has(cell.tileId))
            tilemapError("TILE_NOT_FOUND", `Room references missing source tile ${cell.tileId}.`);
    }
    for (const region of room.protectedRegions) {
        if (region.x + region.width > room.width || region.y + region.height > room.height) {
            tilemapError("TILEMAP_OUT_OF_BOUNDS", "A protected tile region extends outside this room.");
        }
    }
}
export function renderTilemap(room, atlas) {
    validateRoomCells(room, atlas);
    const image = new PNG({ width: room.width * TILEMAP_TILE_SIZE, height: room.height * TILEMAP_TILE_SIZE });
    for (let index = 0; index < room.cells.length; index++) {
        const tile = atlas.tiles.get(room.cells[index].tileId);
        const x = index % room.width;
        const y = Math.floor(index / room.width);
        for (let row = 0; row < 8; row++) {
            tile.copy(image.data, ((y * 8 + row) * image.width + x * 8) * 4, row * 8 * 4, (row + 1) * 8 * 4);
        }
    }
    return image;
}
export function applyTilemapOperations(room, atlas, operations) {
    const cells = room.cells.map((cell) => ({ ...cell }));
    const primitives = new Map(atlas.primitives.map((primitive) => [primitive.id, primitive]));
    let visited = 0;
    for (const operation of operations) {
        if (operation.type === "replace_cells" || operation.type === "set_collision_cells" || operation.type === "adopt_binding")
            tilemapError("INVALID_TILEMAP_OPERATION", "Replacement, collision assignment or adoption must be the sole operation and use its complete preimages.");
        const primitive = primitives.get(operation.primitiveId);
        if (!primitive)
            tilemapError("PRIMITIVE_NOT_FOUND", `No primitive ${operation.primitiveId} exists in this atlas.`);
        if (operation.type !== "stamp" && (primitive.width !== 1 || primitive.height !== 1)) {
            tilemapError("INVALID_TILEMAP_OPERATION", "Place and fill require a 1 × 1 primitive; use stamp for larger motifs.");
        }
        const x = operation.x - primitive.anchor.x;
        const y = operation.y - primitive.anchor.y;
        const width = operation.type === "fill" ? operation.width : primitive.width;
        const height = operation.type === "fill" ? operation.height : primitive.height;
        if (x < 0 || y < 0 || x + width > room.width || y + height > room.height) {
            tilemapError("TILEMAP_OUT_OF_BOUNDS", "The complete anchored placement must fit the room; no clipping is performed.");
        }
        for (let row = 0; row < height; row++) {
            for (let column = 0; column < width; column++) {
                const cell = primitive.cells[operation.type === "fill" ? 0 : row * primitive.width + column];
                if (cell === null)
                    continue;
                if (++visited > MAX_EDIT_CELLS)
                    tilemapError("TILEMAP_LIMIT", `One edit may visit at most ${MAX_EDIT_CELLS} nonempty cells, including repeated visits.`);
                const targetX = x + column;
                const targetY = y + row;
                if (room.protectedRegions.some((region) => targetX >= region.x && targetX < region.x + region.width && targetY >= region.y && targetY < region.y + region.height)) {
                    tilemapError("TILEMAP_PROTECTED_REGION", `Placement touches protected tile (${targetX}, ${targetY}).`);
                }
                const index = targetY * room.width + targetX;
                const previous = cells[index];
                if (cell.paletteSlot !== undefined && room.reservedPaletteSlots.includes(cell.paletteSlot)) {
                    tilemapError("TILEMAP_RESERVED_PALETTE", `Background palette slot ${cell.paletteSlot} is reserved by this recipe.`);
                }
                if (cell.paletteSlot !== undefined && previous.paletteByte >= 8) {
                    tilemapError("UNSUPPORTED_TILE_COLOR_ATTRIBUTES", `Tile (${targetX}, ${targetY}) has opaque palette attributes; omit palette editing to preserve them.`);
                }
                cells[index] = {
                    tileId: cell.tileId,
                    primitiveId: primitive.id,
                    paletteByte: cell.paletteSlot ?? previous.paletteByte,
                    collisionByte: cell.collision === undefined
                        ? previous.collisionByte
                        : (previous.collisionByte & (255 ^ cell.collision.mask)) | cell.collision.value,
                };
            }
        }
    }
    return { ...room, cells };
}
/** Validate the whole selection first; neither protection nor collision data is editable here. */
export function replaceTilemapCells(room, atlas, operation) {
    const selected = new Set();
    for (const cell of operation.cells) {
        if (cell.x >= room.width || cell.y >= room.height)
            tilemapError("TILEMAP_OUT_OF_BOUNDS", "Every selected replacement must fit the room; no clipping is performed.");
        const index = cell.y * room.width + cell.x;
        if (selected.has(index))
            tilemapError("INVALID_TILEMAP_OPERATION", `Repeated replacement for tile (${cell.x}, ${cell.y}).`);
        selected.add(index);
        const previous = room.cells[index];
        if (atlas.patterns.get(previous.tileId) !== cell.before.pixelsSha256 || previous.paletteByte !== cell.before.paletteByte || previous.collisionByte !== cell.before.collisionByte) {
            tilemapError("TILEMAP_PREIMAGE_MISMATCH", `Selected tile (${cell.x}, ${cell.y}) does not match its complete pixel/palette/collision preimage.`);
        }
        if (!atlas.tiles.has(cell.after.tileId))
            tilemapError("TILE_NOT_FOUND", `No source tile ${cell.after.tileId} exists in the selected atlas.`);
        if (atlas.patterns.get(cell.after.tileId) !== cell.after.pixelsSha256)
            tilemapError("TILEMAP_PREIMAGE_MISMATCH", `Replacement tile ${cell.after.tileId} does not match its selected pixel hash.`);
        if (previous.paletteByte !== cell.after.paletteByte && (previous.paletteByte >= 8 || cell.after.paletteByte >= 8)) {
            tilemapError("UNSUPPORTED_TILE_COLOR_ATTRIBUTES", `Tile (${cell.x}, ${cell.y}) has opaque palette attributes; preserve its entire palette byte for pixel-only replacement.`);
        }
    }
    // Keep all unselected records, including primitive identity; do not recapture the room.
    const cells = [...room.cells];
    for (const cell of operation.cells) {
        const index = cell.y * room.width + cell.x;
        const previous = room.cells[index];
        if (previous.tileId === cell.after.tileId && previous.paletteByte === cell.after.paletteByte)
            continue;
        cells[index] = { tileId: cell.after.tileId, paletteByte: cell.after.paletteByte, collisionByte: previous.collisionByte };
    }
    return { ...room, cells };
}
/** Assign only explicitly selected collision bytes; protected regions and visual data stay intact. */
export function setTilemapCollisionCells(room, atlas, operation) {
    const selected = new Set();
    for (const cell of operation.cells) {
        if (!Number.isInteger(cell.x) || !Number.isInteger(cell.y) || cell.x < 0 || cell.y < 0 || cell.x >= room.width || cell.y >= room.height) {
            tilemapError("TILEMAP_OUT_OF_BOUNDS", "Every selected collision assignment must fit the room; no clipping is performed.");
        }
        const index = cell.y * room.width + cell.x;
        if (selected.has(index))
            tilemapError("INVALID_TILEMAP_OPERATION", `Repeated collision assignment for tile (${cell.x}, ${cell.y}).`);
        selected.add(index);
        const previous = room.cells[index];
        if (atlas.patterns.get(previous.tileId) !== cell.before.pixelsSha256 || previous.paletteByte !== cell.before.paletteByte || previous.collisionByte !== cell.before.collisionByte) {
            tilemapError("TILEMAP_PREIMAGE_MISMATCH", `Selected tile (${cell.x}, ${cell.y}) does not match its complete pixel/palette/collision preimage.`);
        }
        if (!Number.isInteger(cell.after.collisionByte) || cell.after.collisionByte < 0 || cell.after.collisionByte > 255) {
            tilemapError("INVALID_TILEMAP_INPUT", "An assigned collision byte must be an integer between 0 and 255.");
        }
    }
    // Validate the complete selection before changing any record. Unlike visual
    // replacement, collision assignment retains the selected primitive identity.
    const cells = [...room.cells];
    for (const cell of operation.cells) {
        const index = cell.y * room.width + cell.x;
        const previous = room.cells[index];
        if (previous.collisionByte !== cell.after.collisionByte)
            cells[index] = { ...previous, collisionByte: cell.after.collisionByte };
    }
    return { ...room, cells };
}
export function tilemapDiff(before, after, width, atlas) {
    const beforePatterns = new Set(before.map((cell) => atlas.patterns.get(cell.tileId)));
    const afterPatterns = new Set(after.map((cell) => atlas.patterns.get(cell.tileId)));
    const samples = [];
    let tileIds = 0;
    let primitiveIds = 0;
    let pixelTiles = 0;
    let paletteCells = 0;
    let collisionCells = 0;
    let changedCells = 0;
    for (let index = 0; index < after.length; index++) {
        const left = before[index];
        const right = after[index];
        const changed = left.tileId !== right.tileId || left.primitiveId !== right.primitiveId || left.paletteByte !== right.paletteByte || left.collisionByte !== right.collisionByte;
        tileIds += Number(left.tileId !== right.tileId);
        primitiveIds += Number(left.primitiveId !== right.primitiveId);
        pixelTiles += Number(atlas.patterns.get(left.tileId) !== atlas.patterns.get(right.tileId));
        paletteCells += Number(left.paletteByte !== right.paletteByte);
        collisionCells += Number(left.collisionByte !== right.collisionByte);
        if (changed) {
            changedCells++;
            if (samples.length < 32)
                samples.push({ x: index % width, y: Math.floor(index / width), before: left, after: right });
        }
    }
    return {
        changedCells, tileIds, primitiveIds, pixelTiles, paletteCells, collisionCells,
        uniquePatterns: {
            before: beforePatterns.size, after: afterPatterns.size,
            added: [...afterPatterns].filter((pattern) => !beforePatterns.has(pattern)).length,
            retired: [...beforePatterns].filter((pattern) => !afterPatterns.has(pattern)).length,
            net: afterPatterns.size - beforePatterns.size,
            flipDeduplication: false,
        },
        samples,
        truncated: changedCells > samples.length,
    };
}
//# sourceMappingURL=tilemap-compose.js.map

SHA-256: 7518c6ad111ec3e909f9f0543cd9d25e56f4799c9391065c939a0ab11e04d3e1