Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 131 additions & 2 deletions assets/toolbox/sprites/js/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const DEFAULT_GRID_SIZE = 16;
const SUPPORTED_GRID_SIZES = Object.freeze([16, 32]);
const SUPPORTED_ZOOM_LEVELS = Object.freeze([1, 2, 4]);
const EDITOR_TOOLS = Object.freeze(["pencil", "eraser", "fill", "picker", "zoom"]);
const SHAPE_TOOLS = Object.freeze(["line", "rectangle", "circle"]);
const EDITOR_TOOLS = Object.freeze(["pencil", "eraser", "fill", "picker", "zoom", ...SHAPE_TOOLS]);
const EDITOR_COLOR_KEYS = Object.freeze(["ink", "orange", "gold", "green", "blue"]);
const EDITOR_COLOR_CSS_VARIABLES = Object.freeze({
blue: "--electric-blue",
Expand All @@ -16,6 +17,7 @@ const editorState = {
activeColor: "ink",
gridSize: DEFAULT_GRID_SIZE,
paintedPixels: new Map(),
shapeAnchor: null,
zoomLevel: 1,
};
const editorHistory = {
Expand All @@ -35,6 +37,17 @@ function pixelKey(row, column) {
return `${row}:${column}`;
}

function cellCoordinates(cell) {
return {
column: Number(cell.dataset.spritePixelColumn),
row: Number(cell.dataset.spritePixelRow),
};
}

function isInsideGrid(row, column) {
return row >= 1 && row <= editorState.gridSize && column >= 1 && column <= editorState.gridSize;
}

function stateSnapshot() {
return {
gridSize: editorState.gridSize,
Expand Down Expand Up @@ -132,6 +145,7 @@ function applyPaintedPixelsToGrid() {
function applySnapshot(snapshot) {
setGridSize(snapshot.gridSize, { recordHistory: false });
editorState.paintedPixels = new Map(snapshot.paintedPixels);
editorState.shapeAnchor = null;
applyPaintedPixelsToGrid();
updateDraftStatus();
updateHistoryControls();
Expand Down Expand Up @@ -168,14 +182,19 @@ function setActiveTool(toolName) {
return;
}
editorState.activeTool = toolName;
editorState.shapeAnchor = null;
document.querySelectorAll("[data-sprite-tool-button]").forEach((button) => {
const isActive = button.dataset.spriteToolButton === toolName;
button.classList.toggle("primary", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
const status = document.querySelector("[data-sprites-tool-status]");
if (status) {
status.textContent = `${toolName[0].toUpperCase()}${toolName.slice(1)} is active. Drawing stays in unsaved editor state for this page session only.`;
if (SHAPE_TOOLS.includes(toolName)) {
status.textContent = `${toolName[0].toUpperCase()}${toolName.slice(1)} is active. Choose a start pixel, then an end pixel. Drawing stays in unsaved editor state for this page session only.`;
} else {
status.textContent = `${toolName[0].toUpperCase()}${toolName.slice(1)} is active. Drawing stays in unsaved editor state for this page session only.`;
}
}
}

Expand Down Expand Up @@ -215,6 +234,109 @@ function pickCellColor(cell) {
}
}

function setPixel(row, column, colorKey) {
if (!isInsideGrid(row, column)) {
return;
}
const key = pixelKey(row, column);
const normalizedColorKey = normalizeColorKey(colorKey);
const cell = document.querySelector(`[data-sprite-pixel-row="${row}"][data-sprite-pixel-column="${column}"]`);
editorState.paintedPixels.set(key, normalizedColorKey);
if (cell) {
setCellColor(cell, normalizedColorKey);
}
}

function drawLine(start, end) {
let row = start.row;
let column = start.column;
const rowStep = Math.sign(end.row - start.row);
const columnStep = Math.sign(end.column - start.column);
const rowDistance = Math.abs(end.row - start.row);
const columnDistance = Math.abs(end.column - start.column);
let error = columnDistance - rowDistance;

while (true) {
setPixel(row, column, editorState.activeColor);
if (row === end.row && column === end.column) {
break;
}
const doubledError = error * 2;
if (doubledError > -rowDistance) {
error -= rowDistance;
column += columnStep;
}
if (doubledError < columnDistance) {
error += columnDistance;
row += rowStep;
}
}
}

function drawRectangle(start, end) {
const minRow = Math.min(start.row, end.row);
const maxRow = Math.max(start.row, end.row);
const minColumn = Math.min(start.column, end.column);
const maxColumn = Math.max(start.column, end.column);
for (let column = minColumn; column <= maxColumn; column += 1) {
setPixel(minRow, column, editorState.activeColor);
setPixel(maxRow, column, editorState.activeColor);
}
for (let row = minRow; row <= maxRow; row += 1) {
setPixel(row, minColumn, editorState.activeColor);
setPixel(row, maxColumn, editorState.activeColor);
}
}

function drawCircle(start, end) {
const radius = Math.max(Math.abs(end.row - start.row), Math.abs(end.column - start.column));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the clicked circle endpoint as the radius

When the circle end pixel is diagonal from the start by more than one cell, this Chebyshev radius does not match the Euclidean perimeter test below, so the user-selected end pixel is often not painted at all (for example, center (5,5) and end (7,7) gives radius 2, but that endpoint is distance 2.83 and fails the <= 0.6 threshold). Since the tool asks users to choose a start and end pixel for the circle, diagonal radius clicks produce a circle that does not pass through the chosen endpoint; derive the radius consistently with the perimeter test or explicitly rasterize through the selected end.

Useful? React with 👍 / 👎.

if (radius === 0) {
setPixel(start.row, start.column, editorState.activeColor);
return;
}
for (let rowOffset = -radius; rowOffset <= radius; rowOffset += 1) {
for (let columnOffset = -radius; columnOffset <= radius; columnOffset += 1) {
const distance = Math.sqrt(rowOffset * rowOffset + columnOffset * columnOffset);
if (Math.abs(distance - radius) <= 0.6) {
setPixel(start.row + rowOffset, start.column + columnOffset, editorState.activeColor);
}
}
}
}

function drawShape(start, end, toolName) {
if (toolName === "line") {
drawLine(start, end);
} else if (toolName === "rectangle") {
drawRectangle(start, end);
} else if (toolName === "circle") {
drawCircle(start, end);
}
}

function useShapeTool(cell) {
const coordinates = cellCoordinates(cell);
const status = document.querySelector("[data-sprites-tool-status]");
if (!Number.isFinite(coordinates.row) || !Number.isFinite(coordinates.column)) {
return;
}
if (!editorState.shapeAnchor || editorState.shapeAnchor.toolName !== editorState.activeTool) {
editorState.shapeAnchor = { ...coordinates, toolName: editorState.activeTool };
if (status) {
status.textContent = `${editorState.activeTool[0].toUpperCase()}${editorState.activeTool.slice(1)} start selected at row ${coordinates.row}, column ${coordinates.column}. Choose an end pixel.`;
}
return;
}

pushUndoSnapshot();
drawShape(editorState.shapeAnchor, coordinates, editorState.activeTool);
if (status) {
status.textContent = `${editorState.activeTool[0].toUpperCase()}${editorState.activeTool.slice(1)} added to the unsaved editor draft.`;
}
editorState.shapeAnchor = null;
updateDraftStatus();
}

function paintCell(cell) {
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
if (editorState.activeTool === "picker") {
Expand All @@ -224,6 +346,10 @@ function paintCell(cell) {
if (editorState.activeTool === "zoom") {
return;
}
if (SHAPE_TOOLS.includes(editorState.activeTool)) {
useShapeTool(cell);
return;
}
if (editorState.activeTool === "eraser") {
if (!editorState.paintedPixels.has(key)) {
return;
Expand All @@ -247,6 +373,7 @@ function fillGrid() {
if (!grid) {
return;
}
editorState.shapeAnchor = null;
pushUndoSnapshot();
editorState.paintedPixels.clear();
grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => {
Expand All @@ -259,6 +386,7 @@ function fillGrid() {

function clearCanvas(options = {}) {
const grid = document.querySelector("[data-sprites-pixel-grid]");
editorState.shapeAnchor = null;
if (options.recordHistory !== false && editorState.paintedPixels.size > 0) {
pushUndoSnapshot();
}
Expand Down Expand Up @@ -347,6 +475,7 @@ function setGridSize(size, options = {}) {
grid.replaceChildren();
editorState.gridSize = size;
editorState.paintedPixels.clear();
editorState.shapeAnchor = null;
grid.dataset.spritesGridSize = String(size);
grid.setAttribute("aria-label", gridLabel(size));

Expand Down
37 changes: 32 additions & 5 deletions dev/tests/playwright/tools/SpritesToolShell.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..", "..", "..");
const SPRITE_TOOLBAR_PLACEHOLDERS = [
"Line",
"Rectangle",
"Circle",
"Move",
];

Expand Down Expand Up @@ -228,6 +225,9 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
await expect(page.getByRole("button", { name: "Pencil tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Eraser tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Fill tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Line tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Rectangle tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Circle tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Picker tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Zoom tool" })).toBeEnabled();
for (const toolName of SPRITE_TOOLBAR_PLACEHOLDERS) {
Expand Down Expand Up @@ -315,11 +315,38 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
await expect(page.locator("[data-sprites-zoom-status]")).toContainText("400%");
await page.getByRole("button", { name: "100%" }).click();
await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "1");
const gridCells = page.locator("[data-sprites-pixel-grid] [role='gridcell']");
await page.getByRole("button", { name: "Clear Canvas" }).click();
await page.getByRole("button", { name: "Green editor color" }).click();
await page.getByRole("button", { name: "Line tool" }).click();
await gridCells.nth(0).click();
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Line start selected");
await gridCells.nth(5).click();
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Line added");
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(6);
await page.getByRole("button", { name: "Clear Canvas" }).click();
await page.getByRole("button", { name: "Rectangle tool" }).click();
await gridCells.nth(0).click();
await gridCells.nth(34).click();
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Rectangle added");
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(8);
await page.getByRole("button", { name: "Clear Canvas" }).click();
await page.getByRole("button", { name: "Circle tool" }).click();
await gridCells.nth(68).click();
await gridCells.nth(71).click();
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Circle added");
const circlePaintedCount = await page.locator("[data-sprites-pixel-grid] .is-painted").count();
expect(circlePaintedCount).toBeGreaterThan(8);
await expect(page.locator("[data-sprites-preview-canvas]")).toBeVisible();
const previewHasPaint = await page.locator("[data-sprites-preview-canvas]").evaluate((canvas) => {
const context = canvas.getContext("2d");
const pixel = context.getImageData(1, 1, 1, 1).data;
return pixel[3] > 0;
const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data;
for (let index = 3; index < imageData.length; index += 4) {
if (imageData[index] > 0) {
return true;
}
}
return false;
});
expect(previewHasPaint).toBe(true);
const downloadPromise = page.waitForEvent("download");
Expand Down
71 changes: 71 additions & 0 deletions docs_build/dev/reports/PR_26179_CHARLIE_032-sprites-shape-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# PR_26179_CHARLIE_032-sprites-shape-tools

Team: CHARLIE

Mode: Batch governance stacked feature workflow

Base branch: PR_26179_CHARLIE_031-sprites-picker-zoom

Branch: PR_26179_CHARLIE_032-sprites-shape-tools

## Summary

Implemented simple pixel-art shape tools for Sprite Creator. Line, Rectangle, and Circle now use a two-click start/end interaction and write only to the unsaved page-session editor draft. Move remains disabled/deferred. No persistence, database, API, schema, browser-owned authoritative product data, or start_of_day files were changed.

## Branch Validation

PASS

- Built on the previous stacked Sprite branch.
- Scope stayed limited to shape tools.
- Move remains disabled/deferred.
- No DB/API/schema changes.
- No product save/load or authoritative browser-owned product data was added.
- No stale PR #219-#228 code was copied.
- ZIP package created at the user-requested batch path.

## Requirement Checklist

PASS - Line tool implemented with simple pixel-art behavior.

PASS - Rectangle tool implemented with outline behavior.

PASS - Circle tool implemented with outline behavior.

PASS - Shape tools use unsaved page-session editor state only.

PASS - No persistence added.

PASS - No DB/API/schema changes.

PASS - No start_of_day changes.

PASS - Targeted Playwright coverage updated.

## Validation Lane

PASS - `node --check assets/toolbox/sprites/js/index.js`

PASS - `node --check dev/tests/playwright/tools/SpritesToolShell.spec.mjs`

PASS - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs`

PASS - Runtime guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files.

PASS - `npx playwright test dev/tests/playwright/tools/SpritesToolShell.spec.mjs --workers=1 --reporter=list`

## Manual Validation Notes

1. Open `toolbox/sprites/index.html`.
2. Select Line, click a start pixel, then click an end pixel.
3. Confirm a straight pixel-art line appears in the unsaved draft.
4. Clear the canvas.
5. Select Rectangle, click opposite corners, and confirm an outline appears.
6. Clear the canvas.
7. Select Circle, click a center pixel, then click a radius pixel.
8. Confirm a simple pixel-art circle outline appears.
9. Confirm no save, API, database, or product persistence workflow is introduced.

## ZIP

`dev/workspace/zip/PR_26179_CHARLIE_032-sprites-shape-tools_delta.zip`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Package the ZIP at the required tmp path

The repo-level AGENTS.md Packaging section requires every BUILD artifact at <project folder>/tmp/<TASK_NAME>_delta.zip, but this report records dev/workspace/zip/PR_26179_CHARLIE_032-sprites-shape-tools_delta.zip instead. Downstream artifact collection that follows the repo contract will miss this delta package even though the validation report says the ZIP was created, so the build should produce/report the required tmp/ path.

Useful? React with 👍 / 👎.

3 changes: 1 addition & 2 deletions docs_build/dev/reports/codex_changed_files.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
assets/theme-v2/css/gamefoundrystudio.css
assets/toolbox/sprites/js/index.js
dev/tests/playwright/tools/SpritesToolShell.spec.mjs
docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md
docs_build/dev/reports/PR_26179_CHARLIE_032-sprites-shape-tools.md
docs_build/dev/reports/codex_changed_files.txt
docs_build/dev/reports/codex_review.diff
toolbox/sprites/index.html
Loading
Loading