Skip to content

Commit 0a2eb22

Browse files
committed
Add Sprite Creator shape tools
1 parent c5803b0 commit 0a2eb22

6 files changed

Lines changed: 500 additions & 238 deletions

File tree

assets/toolbox/sprites/js/index.js

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
const DEFAULT_GRID_SIZE = 16;
22
const SUPPORTED_GRID_SIZES = Object.freeze([16, 32]);
33
const SUPPORTED_ZOOM_LEVELS = Object.freeze([1, 2, 4]);
4-
const EDITOR_TOOLS = Object.freeze(["pencil", "eraser", "fill", "picker", "zoom"]);
4+
const SHAPE_TOOLS = Object.freeze(["line", "rectangle", "circle"]);
5+
const EDITOR_TOOLS = Object.freeze(["pencil", "eraser", "fill", "picker", "zoom", ...SHAPE_TOOLS]);
56
const EDITOR_COLOR_KEYS = Object.freeze(["ink", "orange", "gold", "green", "blue"]);
67
const EDITOR_COLOR_CSS_VARIABLES = Object.freeze({
78
blue: "--electric-blue",
@@ -16,6 +17,7 @@ const editorState = {
1617
activeColor: "ink",
1718
gridSize: DEFAULT_GRID_SIZE,
1819
paintedPixels: new Map(),
20+
shapeAnchor: null,
1921
zoomLevel: 1,
2022
};
2123
const editorHistory = {
@@ -35,6 +37,17 @@ function pixelKey(row, column) {
3537
return `${row}:${column}`;
3638
}
3739

40+
function cellCoordinates(cell) {
41+
return {
42+
column: Number(cell.dataset.spritePixelColumn),
43+
row: Number(cell.dataset.spritePixelRow),
44+
};
45+
}
46+
47+
function isInsideGrid(row, column) {
48+
return row >= 1 && row <= editorState.gridSize && column >= 1 && column <= editorState.gridSize;
49+
}
50+
3851
function stateSnapshot() {
3952
return {
4053
gridSize: editorState.gridSize,
@@ -132,6 +145,7 @@ function applyPaintedPixelsToGrid() {
132145
function applySnapshot(snapshot) {
133146
setGridSize(snapshot.gridSize, { recordHistory: false });
134147
editorState.paintedPixels = new Map(snapshot.paintedPixels);
148+
editorState.shapeAnchor = null;
135149
applyPaintedPixelsToGrid();
136150
updateDraftStatus();
137151
updateHistoryControls();
@@ -168,14 +182,19 @@ function setActiveTool(toolName) {
168182
return;
169183
}
170184
editorState.activeTool = toolName;
185+
editorState.shapeAnchor = null;
171186
document.querySelectorAll("[data-sprite-tool-button]").forEach((button) => {
172187
const isActive = button.dataset.spriteToolButton === toolName;
173188
button.classList.toggle("primary", isActive);
174189
button.setAttribute("aria-pressed", String(isActive));
175190
});
176191
const status = document.querySelector("[data-sprites-tool-status]");
177192
if (status) {
178-
status.textContent = `${toolName[0].toUpperCase()}${toolName.slice(1)} is active. Drawing stays in unsaved editor state for this page session only.`;
193+
if (SHAPE_TOOLS.includes(toolName)) {
194+
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.`;
195+
} else {
196+
status.textContent = `${toolName[0].toUpperCase()}${toolName.slice(1)} is active. Drawing stays in unsaved editor state for this page session only.`;
197+
}
179198
}
180199
}
181200

@@ -215,6 +234,109 @@ function pickCellColor(cell) {
215234
}
216235
}
217236

237+
function setPixel(row, column, colorKey) {
238+
if (!isInsideGrid(row, column)) {
239+
return;
240+
}
241+
const key = pixelKey(row, column);
242+
const normalizedColorKey = normalizeColorKey(colorKey);
243+
const cell = document.querySelector(`[data-sprite-pixel-row="${row}"][data-sprite-pixel-column="${column}"]`);
244+
editorState.paintedPixels.set(key, normalizedColorKey);
245+
if (cell) {
246+
setCellColor(cell, normalizedColorKey);
247+
}
248+
}
249+
250+
function drawLine(start, end) {
251+
let row = start.row;
252+
let column = start.column;
253+
const rowStep = Math.sign(end.row - start.row);
254+
const columnStep = Math.sign(end.column - start.column);
255+
const rowDistance = Math.abs(end.row - start.row);
256+
const columnDistance = Math.abs(end.column - start.column);
257+
let error = columnDistance - rowDistance;
258+
259+
while (true) {
260+
setPixel(row, column, editorState.activeColor);
261+
if (row === end.row && column === end.column) {
262+
break;
263+
}
264+
const doubledError = error * 2;
265+
if (doubledError > -rowDistance) {
266+
error -= rowDistance;
267+
column += columnStep;
268+
}
269+
if (doubledError < columnDistance) {
270+
error += columnDistance;
271+
row += rowStep;
272+
}
273+
}
274+
}
275+
276+
function drawRectangle(start, end) {
277+
const minRow = Math.min(start.row, end.row);
278+
const maxRow = Math.max(start.row, end.row);
279+
const minColumn = Math.min(start.column, end.column);
280+
const maxColumn = Math.max(start.column, end.column);
281+
for (let column = minColumn; column <= maxColumn; column += 1) {
282+
setPixel(minRow, column, editorState.activeColor);
283+
setPixel(maxRow, column, editorState.activeColor);
284+
}
285+
for (let row = minRow; row <= maxRow; row += 1) {
286+
setPixel(row, minColumn, editorState.activeColor);
287+
setPixel(row, maxColumn, editorState.activeColor);
288+
}
289+
}
290+
291+
function drawCircle(start, end) {
292+
const radius = Math.max(Math.abs(end.row - start.row), Math.abs(end.column - start.column));
293+
if (radius === 0) {
294+
setPixel(start.row, start.column, editorState.activeColor);
295+
return;
296+
}
297+
for (let rowOffset = -radius; rowOffset <= radius; rowOffset += 1) {
298+
for (let columnOffset = -radius; columnOffset <= radius; columnOffset += 1) {
299+
const distance = Math.sqrt(rowOffset * rowOffset + columnOffset * columnOffset);
300+
if (Math.abs(distance - radius) <= 0.6) {
301+
setPixel(start.row + rowOffset, start.column + columnOffset, editorState.activeColor);
302+
}
303+
}
304+
}
305+
}
306+
307+
function drawShape(start, end, toolName) {
308+
if (toolName === "line") {
309+
drawLine(start, end);
310+
} else if (toolName === "rectangle") {
311+
drawRectangle(start, end);
312+
} else if (toolName === "circle") {
313+
drawCircle(start, end);
314+
}
315+
}
316+
317+
function useShapeTool(cell) {
318+
const coordinates = cellCoordinates(cell);
319+
const status = document.querySelector("[data-sprites-tool-status]");
320+
if (!Number.isFinite(coordinates.row) || !Number.isFinite(coordinates.column)) {
321+
return;
322+
}
323+
if (!editorState.shapeAnchor || editorState.shapeAnchor.toolName !== editorState.activeTool) {
324+
editorState.shapeAnchor = { ...coordinates, toolName: editorState.activeTool };
325+
if (status) {
326+
status.textContent = `${editorState.activeTool[0].toUpperCase()}${editorState.activeTool.slice(1)} start selected at row ${coordinates.row}, column ${coordinates.column}. Choose an end pixel.`;
327+
}
328+
return;
329+
}
330+
331+
pushUndoSnapshot();
332+
drawShape(editorState.shapeAnchor, coordinates, editorState.activeTool);
333+
if (status) {
334+
status.textContent = `${editorState.activeTool[0].toUpperCase()}${editorState.activeTool.slice(1)} added to the unsaved editor draft.`;
335+
}
336+
editorState.shapeAnchor = null;
337+
updateDraftStatus();
338+
}
339+
218340
function paintCell(cell) {
219341
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
220342
if (editorState.activeTool === "picker") {
@@ -224,6 +346,10 @@ function paintCell(cell) {
224346
if (editorState.activeTool === "zoom") {
225347
return;
226348
}
349+
if (SHAPE_TOOLS.includes(editorState.activeTool)) {
350+
useShapeTool(cell);
351+
return;
352+
}
227353
if (editorState.activeTool === "eraser") {
228354
if (!editorState.paintedPixels.has(key)) {
229355
return;
@@ -247,6 +373,7 @@ function fillGrid() {
247373
if (!grid) {
248374
return;
249375
}
376+
editorState.shapeAnchor = null;
250377
pushUndoSnapshot();
251378
editorState.paintedPixels.clear();
252379
grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => {
@@ -259,6 +386,7 @@ function fillGrid() {
259386

260387
function clearCanvas(options = {}) {
261388
const grid = document.querySelector("[data-sprites-pixel-grid]");
389+
editorState.shapeAnchor = null;
262390
if (options.recordHistory !== false && editorState.paintedPixels.size > 0) {
263391
pushUndoSnapshot();
264392
}
@@ -347,6 +475,7 @@ function setGridSize(size, options = {}) {
347475
grid.replaceChildren();
348476
editorState.gridSize = size;
349477
editorState.paintedPixels.clear();
478+
editorState.shapeAnchor = null;
350479
grid.dataset.spritesGridSize = String(size);
351480
grid.setAttribute("aria-label", gridLabel(size));
352481

dev/tests/playwright/tools/SpritesToolShell.spec.mjs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ const __filename = fileURLToPath(import.meta.url);
1010
const __dirname = path.dirname(__filename);
1111
const repoRoot = path.resolve(__dirname, "..", "..", "..", "..");
1212
const SPRITE_TOOLBAR_PLACEHOLDERS = [
13-
"Line",
14-
"Rectangle",
15-
"Circle",
1613
"Move",
1714
];
1815

@@ -228,6 +225,9 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
228225
await expect(page.getByRole("button", { name: "Pencil tool" })).toBeEnabled();
229226
await expect(page.getByRole("button", { name: "Eraser tool" })).toBeEnabled();
230227
await expect(page.getByRole("button", { name: "Fill tool" })).toBeEnabled();
228+
await expect(page.getByRole("button", { name: "Line tool" })).toBeEnabled();
229+
await expect(page.getByRole("button", { name: "Rectangle tool" })).toBeEnabled();
230+
await expect(page.getByRole("button", { name: "Circle tool" })).toBeEnabled();
231231
await expect(page.getByRole("button", { name: "Picker tool" })).toBeEnabled();
232232
await expect(page.getByRole("button", { name: "Zoom tool" })).toBeEnabled();
233233
for (const toolName of SPRITE_TOOLBAR_PLACEHOLDERS) {
@@ -315,11 +315,38 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
315315
await expect(page.locator("[data-sprites-zoom-status]")).toContainText("400%");
316316
await page.getByRole("button", { name: "100%" }).click();
317317
await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "1");
318+
const gridCells = page.locator("[data-sprites-pixel-grid] [role='gridcell']");
319+
await page.getByRole("button", { name: "Clear Canvas" }).click();
320+
await page.getByRole("button", { name: "Green editor color" }).click();
321+
await page.getByRole("button", { name: "Line tool" }).click();
322+
await gridCells.nth(0).click();
323+
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Line start selected");
324+
await gridCells.nth(5).click();
325+
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Line added");
326+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(6);
327+
await page.getByRole("button", { name: "Clear Canvas" }).click();
328+
await page.getByRole("button", { name: "Rectangle tool" }).click();
329+
await gridCells.nth(0).click();
330+
await gridCells.nth(34).click();
331+
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Rectangle added");
332+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(8);
333+
await page.getByRole("button", { name: "Clear Canvas" }).click();
334+
await page.getByRole("button", { name: "Circle tool" }).click();
335+
await gridCells.nth(68).click();
336+
await gridCells.nth(71).click();
337+
await expect(page.locator("[data-sprites-tool-status]")).toContainText("Circle added");
338+
const circlePaintedCount = await page.locator("[data-sprites-pixel-grid] .is-painted").count();
339+
expect(circlePaintedCount).toBeGreaterThan(8);
318340
await expect(page.locator("[data-sprites-preview-canvas]")).toBeVisible();
319341
const previewHasPaint = await page.locator("[data-sprites-preview-canvas]").evaluate((canvas) => {
320342
const context = canvas.getContext("2d");
321-
const pixel = context.getImageData(1, 1, 1, 1).data;
322-
return pixel[3] > 0;
343+
const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data;
344+
for (let index = 3; index < imageData.length; index += 4) {
345+
if (imageData[index] > 0) {
346+
return true;
347+
}
348+
}
349+
return false;
323350
});
324351
expect(previewHasPaint).toBe(true);
325352
const downloadPromise = page.waitForEvent("download");
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# PR_26179_CHARLIE_032-sprites-shape-tools
2+
3+
Team: CHARLIE
4+
5+
Mode: Batch governance stacked feature workflow
6+
7+
Base branch: PR_26179_CHARLIE_031-sprites-picker-zoom
8+
9+
Branch: PR_26179_CHARLIE_032-sprites-shape-tools
10+
11+
## Summary
12+
13+
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.
14+
15+
## Branch Validation
16+
17+
PASS
18+
19+
- Built on the previous stacked Sprite branch.
20+
- Scope stayed limited to shape tools.
21+
- Move remains disabled/deferred.
22+
- No DB/API/schema changes.
23+
- No product save/load or authoritative browser-owned product data was added.
24+
- No stale PR #219-#228 code was copied.
25+
- ZIP package created at the user-requested batch path.
26+
27+
## Requirement Checklist
28+
29+
PASS - Line tool implemented with simple pixel-art behavior.
30+
31+
PASS - Rectangle tool implemented with outline behavior.
32+
33+
PASS - Circle tool implemented with outline behavior.
34+
35+
PASS - Shape tools use unsaved page-session editor state only.
36+
37+
PASS - No persistence added.
38+
39+
PASS - No DB/API/schema changes.
40+
41+
PASS - No start_of_day changes.
42+
43+
PASS - Targeted Playwright coverage updated.
44+
45+
## Validation Lane
46+
47+
PASS - `node --check assets/toolbox/sprites/js/index.js`
48+
49+
PASS - `node --check dev/tests/playwright/tools/SpritesToolShell.spec.mjs`
50+
51+
PASS - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs`
52+
53+
PASS - Runtime guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files.
54+
55+
PASS - `npx playwright test dev/tests/playwright/tools/SpritesToolShell.spec.mjs --workers=1 --reporter=list`
56+
57+
## Manual Validation Notes
58+
59+
1. Open `toolbox/sprites/index.html`.
60+
2. Select Line, click a start pixel, then click an end pixel.
61+
3. Confirm a straight pixel-art line appears in the unsaved draft.
62+
4. Clear the canvas.
63+
5. Select Rectangle, click opposite corners, and confirm an outline appears.
64+
6. Clear the canvas.
65+
7. Select Circle, click a center pixel, then click a radius pixel.
66+
8. Confirm a simple pixel-art circle outline appears.
67+
9. Confirm no save, API, database, or product persistence workflow is introduced.
68+
69+
## ZIP
70+
71+
`dev/workspace/zip/PR_26179_CHARLIE_032-sprites-shape-tools_delta.zip`
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
assets/theme-v2/css/gamefoundrystudio.css
21
assets/toolbox/sprites/js/index.js
32
dev/tests/playwright/tools/SpritesToolShell.spec.mjs
4-
docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md
3+
docs_build/dev/reports/PR_26179_CHARLIE_032-sprites-shape-tools.md
54
docs_build/dev/reports/codex_changed_files.txt
65
docs_build/dev/reports/codex_review.diff
76
toolbox/sprites/index.html

0 commit comments

Comments
 (0)