brickblock

This commit is contained in:
Oli Passey
2026-02-21 18:12:10 +00:00
parent 22f7b3c4a9
commit 68cf0fcdc3
3 changed files with 149 additions and 8 deletions

View File

@@ -8,6 +8,7 @@ A lightweight, browser-based tool to build a tile library and paint reusable pat
- Support rectangular tiles (multi-cell) - Support rectangular tiles (multi-cell)
- Pin a floor tile row at the bottom - Pin a floor tile row at the bottom
- Fill any row with a chosen tile - Fill any row with a chosen tile
- Apply a brick-block layout across rows
- Auto-save tile library in the browser - Auto-save tile library in the browser
- Import/export the tile library as JSON - Import/export the tile library as JSON
- Export JSON for handing off to a tiler - Export JSON for handing off to a tiler
@@ -59,5 +60,6 @@ Open `index.html` directly in your browser, or use the optional local server scr
- Shift-click or right click to erase a tile. - Shift-click or right click to erase a tile.
- Enable “Floor row” to lock the bottom row to a chosen tile. - Enable “Floor row” to lock the bottom row to a chosen tile.
- Use “Fill row” to quickly paint a full row with a tile. - Use “Fill row” to quickly paint a full row with a tile.
- Use “Apply brick” to stagger rows in a brick pattern.
- Tile library changes auto-save to your browser storage. - Tile library changes auto-save to your browser storage.
- If you use image tiles, keep images square for best results. - If you use image tiles, keep images square for best results.

120
app.js
View File

@@ -19,6 +19,11 @@ const floorTileSelect = document.getElementById("floor-tile");
const fillRowInput = document.getElementById("fill-row"); const fillRowInput = document.getElementById("fill-row");
const fillRowTileSelect = document.getElementById("fill-row-tile"); const fillRowTileSelect = document.getElementById("fill-row-tile");
const fillRowButton = document.getElementById("fill-row-button"); const fillRowButton = document.getElementById("fill-row-button");
const brickStartInput = document.getElementById("brick-start");
const brickEndInput = document.getElementById("brick-end");
const brickTileSelect = document.getElementById("brick-tile");
const brickStaggerInput = document.getElementById("brick-stagger");
const brickApplyButton = document.getElementById("brick-apply");
const applyGridButton = document.getElementById("apply-grid"); const applyGridButton = document.getElementById("apply-grid");
const clearGridButton = document.getElementById("clear-grid"); const clearGridButton = document.getElementById("clear-grid");
@@ -148,11 +153,13 @@ const setActiveTile = (tileId) => {
const renderFloorOptions = () => { const renderFloorOptions = () => {
floorTileSelect.innerHTML = ""; floorTileSelect.innerHTML = "";
fillRowTileSelect.innerHTML = ""; fillRowTileSelect.innerHTML = "";
brickTileSelect.innerHTML = "";
const placeholder = document.createElement("option"); const placeholder = document.createElement("option");
placeholder.value = ""; placeholder.value = "";
placeholder.textContent = "Select tile"; placeholder.textContent = "Select tile";
floorTileSelect.append(placeholder); floorTileSelect.append(placeholder);
fillRowTileSelect.append(placeholder.cloneNode(true)); fillRowTileSelect.append(placeholder.cloneNode(true));
brickTileSelect.append(placeholder.cloneNode(true));
tiles.forEach((tile) => { tiles.forEach((tile) => {
const option = document.createElement("option"); const option = document.createElement("option");
@@ -160,6 +167,7 @@ const renderFloorOptions = () => {
option.textContent = tile.name; option.textContent = tile.name;
floorTileSelect.append(option); floorTileSelect.append(option);
fillRowTileSelect.append(option.cloneNode(true)); fillRowTileSelect.append(option.cloneNode(true));
brickTileSelect.append(option.cloneNode(true));
}); });
if (floorTileId && !tiles.some((tile) => tile.id === floorTileId)) { if (floorTileId && !tiles.some((tile) => tile.id === floorTileId)) {
@@ -167,6 +175,7 @@ const renderFloorOptions = () => {
} }
floorTileSelect.value = floorTileId ?? ""; floorTileSelect.value = floorTileId ?? "";
fillRowTileSelect.value = activeTileId ?? ""; fillRowTileSelect.value = activeTileId ?? "";
brickTileSelect.value = activeTileId ?? "";
}; };
const renderTiles = () => { const renderTiles = () => {
@@ -274,18 +283,75 @@ const drawGridLines = () => {
patternContext.strokeStyle = gridLineColor; patternContext.strokeStyle = gridLineColor;
patternContext.lineWidth = 1; patternContext.lineWidth = 1;
const lastRowIndex = config.rows - 1;
const floorTile = floorTileId ? getTileById(floorTileId) : null;
for (let row = 0; row <= config.rows; row += 1) { for (let row = 0; row <= config.rows; row += 1) {
if (row === 0 || row === config.rows) {
patternContext.beginPath(); patternContext.beginPath();
patternContext.moveTo(0, row * config.tileSize + 0.5); patternContext.moveTo(0, row * config.tileSize + 0.5);
patternContext.lineTo(patternCanvas.width, row * config.tileSize + 0.5); patternContext.lineTo(patternCanvas.width, row * config.tileSize + 0.5);
patternContext.stroke(); patternContext.stroke();
continue;
}
let segmentStart = 0;
for (let col = 0; col < config.cols; col += 1) {
const topId = grid[row - 1][col];
const bottomId = grid[row][col];
const shouldSkip = topId && bottomId && topId === bottomId;
if (shouldSkip) {
if (segmentStart < col) {
patternContext.beginPath();
patternContext.moveTo(segmentStart * config.tileSize, row * config.tileSize + 0.5);
patternContext.lineTo(col * config.tileSize, row * config.tileSize + 0.5);
patternContext.stroke();
}
segmentStart = col + 1;
}
}
if (segmentStart < config.cols) {
patternContext.beginPath();
patternContext.moveTo(segmentStart * config.tileSize, row * config.tileSize + 0.5);
patternContext.lineTo(patternCanvas.width, row * config.tileSize + 0.5);
patternContext.stroke();
}
} }
for (let col = 0; col <= config.cols; col += 1) { for (let col = 0; col <= config.cols; col += 1) {
if (col === 0 || col === config.cols) {
patternContext.beginPath(); patternContext.beginPath();
patternContext.moveTo(col * config.tileSize + 0.5, 0); patternContext.moveTo(col * config.tileSize + 0.5, 0);
patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height); patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height);
patternContext.stroke(); patternContext.stroke();
continue;
}
let segmentStart = 0;
for (let row = 0; row < config.rows; row += 1) {
const leftId = grid[row][col - 1];
const rightId = grid[row][col];
const isFloorRow = floorEnabled && row === lastRowIndex;
const floorSkip =
isFloorRow && floorTile && floorTile.width > 1 && col % floorTile.width !== 0;
const shouldSkip = (leftId && rightId && leftId === rightId) || floorSkip;
if (shouldSkip) {
if (segmentStart < row) {
patternContext.beginPath();
patternContext.moveTo(col * config.tileSize + 0.5, segmentStart * config.tileSize);
patternContext.lineTo(col * config.tileSize + 0.5, row * config.tileSize);
patternContext.stroke();
}
segmentStart = row + 1;
}
}
if (segmentStart < config.rows) {
patternContext.beginPath();
patternContext.moveTo(col * config.tileSize + 0.5, segmentStart * config.tileSize);
patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height);
patternContext.stroke();
}
} }
}; };
@@ -430,6 +496,41 @@ const fillRowWithTile = (rowIndex, tileId) => {
syncPatternPreview(); syncPatternPreview();
}; };
const fillBrickRows = (startRow, endRow, tileId, stagger) => {
const tile = getTileById(tileId);
if (!tile) {
return;
}
const maxRow = floorEnabled ? config.rows - 2 : config.rows - 1;
const start = clampNumber(startRow, 0, maxRow);
const end = clampNumber(endRow, start, maxRow);
const tileWidth = clampNumber(tile.width, 1, config.cols);
for (let row = start; row <= end; row += 1) {
const offset = stagger && (row - start) % 2 === 1 ? Math.floor(tileWidth / 2) : 0;
const toRemove = new Set();
for (let col = 0; col < config.cols; col += 1) {
const placementId = grid[row][col];
if (placementId) {
toRemove.add(placementId);
}
}
toRemove.forEach((placementId) => clearPlacement(placementId));
let col = 0;
if (offset > 0) {
addPlacement(row, 0, tile.id, clampNumber(offset, 1, config.cols), 1);
col = offset;
}
for (; col < config.cols; col += tileWidth) {
const remaining = config.cols - col;
addPlacement(row, col, tile.id, clampNumber(tileWidth, 1, remaining), 1);
}
}
renderCanvas();
syncPatternPreview();
};
const paintArea = (row, col, tileId, width = 1, height = 1) => { const paintArea = (row, col, tileId, width = 1, height = 1) => {
const maxRow = floorEnabled ? config.rows - 2 : config.rows - 1; const maxRow = floorEnabled ? config.rows - 2 : config.rows - 1;
if (row > maxRow || col >= config.cols) { if (row > maxRow || col >= config.cols) {
@@ -516,6 +617,9 @@ applyGridButton.addEventListener("click", () => {
gridRowsInput.value = config.rows; gridRowsInput.value = config.rows;
gridColsInput.value = config.cols; gridColsInput.value = config.cols;
gridSizeInput.value = config.tileSize; gridSizeInput.value = config.tileSize;
fillRowInput.max = String(config.rows);
brickStartInput.max = String(config.rows);
brickEndInput.max = String(config.rows);
initGrid(); initGrid();
renderCanvas(); renderCanvas();
syncPatternPreview(); syncPatternPreview();
@@ -550,6 +654,16 @@ fillRowButton.addEventListener("click", () => {
fillRowWithTile(rowIndex, tileId); fillRowWithTile(rowIndex, tileId);
}); });
brickApplyButton.addEventListener("click", () => {
const startRow = Number(brickStartInput.value) - 1;
const endRow = Number(brickEndInput.value) - 1;
const tileId = brickTileSelect.value || activeTileId;
if (!tileId) {
return;
}
fillBrickRows(startRow, endRow, tileId, brickStaggerInput.checked);
});
const readFileAsDataUrl = (file) => const readFileAsDataUrl = (file) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -744,6 +858,9 @@ importJsonButton.addEventListener("click", () => {
gridSizeInput.value = config.tileSize; gridSizeInput.value = config.tileSize;
floorEnabledInput.checked = floorEnabled; floorEnabledInput.checked = floorEnabled;
fillRowInput.value = "1"; fillRowInput.value = "1";
fillRowInput.max = String(config.rows);
brickStartInput.max = String(config.rows);
brickEndInput.max = String(config.rows);
tiles = normalizeTiles(payload.tiles); tiles = normalizeTiles(payload.tiles);
@@ -824,3 +941,6 @@ loadLibraryFromStorage();
renderTiles(); renderTiles();
renderCanvas(); renderCanvas();
syncPatternPreview(); syncPatternPreview();
fillRowInput.max = String(config.rows);
brickStartInput.max = String(config.rows);
brickEndInput.max = String(config.rows);

View File

@@ -97,6 +97,25 @@
</label> </label>
<button id="fill-row-button">Fill row</button> <button id="fill-row-button">Fill row</button>
</div> </div>
<div class="control-group">
<label>
Brick start row
<input id="brick-start" type="number" min="1" max="100" value="1" />
</label>
<label>
Brick end row
<input id="brick-end" type="number" min="1" max="100" value="6" />
</label>
<label>
Brick tile
<select id="brick-tile"></select>
</label>
<label class="checkbox">
<input id="brick-stagger" type="checkbox" checked />
Stagger rows
</label>
<button id="brick-apply">Apply brick</button>
</div>
<div class="control-actions"> <div class="control-actions">
<button id="apply-grid">Apply grid</button> <button id="apply-grid">Apply grid</button>
<button id="clear-grid">Clear grid</button> <button id="clear-grid">Clear grid</button>