Skip to content

Commit 343fbdf

Browse files
authored
Merge pull request #272 from ToolboxAid/PR_26179_CHARLIE_035-sprites-frame-editing
PR_26179_CHARLIE_035-sprites-frame-editing
2 parents ce49ce7 + eae8719 commit 343fbdf

6 files changed

Lines changed: 528 additions & 212 deletions

File tree

assets/toolbox/sprites/js/index.js

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const DEFAULT_GRID_SIZE = 16;
2+
const DEFAULT_FRAME_DURATION_MS = 120;
23
const SUPPORTED_GRID_SIZES = Object.freeze([16, 32]);
34
const SUPPORTED_ZOOM_LEVELS = Object.freeze([1, 2, 4]);
45
const SHAPE_TOOLS = Object.freeze(["line", "rectangle", "circle"]);
@@ -12,11 +13,22 @@ const EDITOR_COLOR_CSS_VARIABLES = Object.freeze({
1213
orange: "--molten-orange",
1314
});
1415

16+
function createEditorFrame(frameNumber, paintedPixels = new Map()) {
17+
return {
18+
durationMs: DEFAULT_FRAME_DURATION_MS,
19+
frameNumber,
20+
paintedPixels: new Map(paintedPixels),
21+
};
22+
}
23+
24+
const initialFrame = createEditorFrame(1);
1525
const editorState = {
1626
activeTool: "pencil",
1727
activeColor: "ink",
28+
activeFrameIndex: 0,
29+
frames: [initialFrame],
1830
gridSize: DEFAULT_GRID_SIZE,
19-
paintedPixels: new Map(),
31+
paintedPixels: initialFrame.paintedPixels,
2032
shapeAnchor: null,
2133
zoomLevel: 1,
2234
};
@@ -48,8 +60,23 @@ function isInsideGrid(row, column) {
4860
return row >= 1 && row <= editorState.gridSize && column >= 1 && column <= editorState.gridSize;
4961
}
5062

63+
function currentFrame() {
64+
return editorState.frames[editorState.activeFrameIndex] || editorState.frames[0];
65+
}
66+
67+
function syncActiveFramePixels() {
68+
editorState.paintedPixels = currentFrame().paintedPixels;
69+
}
70+
71+
function renumberFrames() {
72+
editorState.frames.forEach((frame, index) => {
73+
frame.frameNumber = index + 1;
74+
});
75+
}
76+
5177
function stateSnapshot() {
5278
return {
79+
activeFrameIndex: editorState.activeFrameIndex,
5380
gridSize: editorState.gridSize,
5481
paintedPixels: Array.from(editorState.paintedPixels.entries()),
5582
};
@@ -144,7 +171,10 @@ function applyPaintedPixelsToGrid() {
144171

145172
function applySnapshot(snapshot) {
146173
setGridSize(snapshot.gridSize, { recordHistory: false });
147-
editorState.paintedPixels = new Map(snapshot.paintedPixels);
174+
const targetIndex = Math.min(snapshot.activeFrameIndex ?? editorState.activeFrameIndex, editorState.frames.length - 1);
175+
editorState.activeFrameIndex = Math.max(0, targetIndex);
176+
currentFrame().paintedPixels = new Map(snapshot.paintedPixels);
177+
syncActiveFramePixels();
148178
editorState.shapeAnchor = null;
149179
applyPaintedPixelsToGrid();
150180
updateDraftStatus();
@@ -446,14 +476,86 @@ function renderPreview() {
446476
}
447477

448478
function renderFrameStrip() {
449-
const thumbnail = document.querySelector("[data-sprites-frame-thumbnail='0']");
479+
const strip = document.querySelector("[data-sprites-frame-strip]");
450480
const status = document.querySelector("[data-sprites-frame-status]");
451-
if (thumbnail) {
452-
renderPixelsToCanvas(thumbnail, editorState.paintedPixels, editorState.gridSize);
481+
if (strip) {
482+
strip.replaceChildren();
483+
editorState.frames.forEach((frame, index) => {
484+
const button = document.createElement("button");
485+
button.className = `sprite-frame-card${index === editorState.activeFrameIndex ? " is-active" : ""}`;
486+
button.type = "button";
487+
button.dataset.spritesFrameCard = String(index);
488+
button.setAttribute("aria-pressed", String(index === editorState.activeFrameIndex));
489+
const title = document.createElement("span");
490+
title.className = "sprite-frame-card-title";
491+
title.textContent = `Frame ${frame.frameNumber}`;
492+
const thumbnail = document.createElement("canvas");
493+
thumbnail.className = "sprite-frame-thumbnail";
494+
thumbnail.width = 48;
495+
thumbnail.height = 48;
496+
thumbnail.setAttribute("aria-label", `Frame ${frame.frameNumber} thumbnail`);
497+
thumbnail.dataset.spritesFrameThumbnail = String(index);
498+
const meta = document.createElement("span");
499+
meta.className = "sprite-frame-card-meta";
500+
meta.textContent = `${frame.paintedPixels.size} pixel${frame.paintedPixels.size === 1 ? "" : "s"}`;
501+
button.append(title, thumbnail, meta);
502+
button.addEventListener("click", () => selectFrame(index));
503+
strip.append(button);
504+
renderPixelsToCanvas(thumbnail, frame.paintedPixels, editorState.gridSize);
505+
});
453506
}
454507
if (status) {
455-
status.textContent = `Frame strip: 1 unsaved frame. Current editor draft has ${editorState.paintedPixels.size} painted pixel${editorState.paintedPixels.size === 1 ? "" : "s"}.`;
508+
status.textContent = `Frame strip: ${editorState.frames.length} unsaved frame${editorState.frames.length === 1 ? "" : "s"}. Frame ${currentFrame().frameNumber} is selected with ${editorState.paintedPixels.size} painted pixel${editorState.paintedPixels.size === 1 ? "" : "s"}.`;
509+
}
510+
const deleteButton = document.querySelector("[data-sprites-delete-frame]");
511+
if (deleteButton) {
512+
deleteButton.disabled = editorState.frames.length <= 1;
513+
}
514+
}
515+
516+
function selectFrame(index) {
517+
if (index < 0 || index >= editorState.frames.length) {
518+
return;
519+
}
520+
editorState.activeFrameIndex = index;
521+
syncActiveFramePixels();
522+
editorState.shapeAnchor = null;
523+
applyPaintedPixelsToGrid();
524+
updateDraftStatus();
525+
updateHistoryControls();
526+
}
527+
528+
function addFrame() {
529+
editorState.frames.push(createEditorFrame(editorState.frames.length + 1));
530+
editorState.activeFrameIndex = editorState.frames.length - 1;
531+
syncActiveFramePixels();
532+
editorState.shapeAnchor = null;
533+
updateDraftStatus();
534+
updateHistoryControls();
535+
}
536+
537+
function duplicateFrame() {
538+
const duplicate = createEditorFrame(editorState.frames.length + 1, editorState.paintedPixels);
539+
editorState.frames.push(duplicate);
540+
editorState.activeFrameIndex = editorState.frames.length - 1;
541+
syncActiveFramePixels();
542+
editorState.shapeAnchor = null;
543+
updateDraftStatus();
544+
updateHistoryControls();
545+
}
546+
547+
function deleteFrame() {
548+
if (editorState.frames.length <= 1) {
549+
return;
456550
}
551+
editorState.frames.splice(editorState.activeFrameIndex, 1);
552+
renumberFrames();
553+
editorState.activeFrameIndex = Math.max(0, Math.min(editorState.activeFrameIndex, editorState.frames.length - 1));
554+
syncActiveFramePixels();
555+
editorState.shapeAnchor = null;
556+
applyPaintedPixelsToGrid();
557+
updateDraftStatus();
558+
updateHistoryControls();
457559
}
458560

459561
function exportPreviewPng() {
@@ -497,7 +599,10 @@ function setGridSize(size, options = {}) {
497599

498600
grid.replaceChildren();
499601
editorState.gridSize = size;
500-
editorState.paintedPixels.clear();
602+
editorState.frames.forEach((frame) => {
603+
frame.paintedPixels.clear();
604+
});
605+
syncActiveFramePixels();
501606
editorState.shapeAnchor = null;
502607
grid.dataset.spritesGridSize = String(size);
503608
grid.setAttribute("aria-label", gridLabel(size));
@@ -579,6 +684,23 @@ function wireCanvasActions() {
579684
}
580685
}
581686

687+
function wireFrameActions() {
688+
const addButton = document.querySelector("[data-sprites-add-frame]");
689+
if (addButton) {
690+
addButton.addEventListener("click", addFrame);
691+
}
692+
693+
const duplicateButton = document.querySelector("[data-sprites-duplicate-frame]");
694+
if (duplicateButton) {
695+
duplicateButton.addEventListener("click", duplicateFrame);
696+
}
697+
698+
const deleteButton = document.querySelector("[data-sprites-delete-frame]");
699+
if (deleteButton) {
700+
deleteButton.addEventListener("click", deleteFrame);
701+
}
702+
}
703+
582704
function wireHistoryActions() {
583705
const undoButton = document.querySelector("[data-sprites-undo]");
584706
if (undoButton) {
@@ -618,6 +740,7 @@ wireGridControls();
618740
wireDrawingTools();
619741
wirePaletteButtons();
620742
wireCanvasActions();
743+
wireFrameActions();
621744
wireHistoryActions();
622745
wireZoomControls();
623746
wireExportButton();

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,3 +487,51 @@ test("Sprite Creator keeps center canvas and right preview in sync", async ({ pa
487487
await server.close();
488488
}
489489
});
490+
491+
test("Sprite Creator edits unsaved frame strip frames", async ({ page }) => {
492+
const server = await startSpriteShellTestServer();
493+
const failures = collectPageFailures(page);
494+
495+
try {
496+
await page.goto(`${server.baseUrl}/toolbox/sprites/index.html`, { waitUntil: "networkidle" });
497+
498+
await page.getByRole("button", { name: "Gold editor color" }).click();
499+
await spriteCell(page, 1, 1).click();
500+
await expectCenterAndPreviewPainted(page, 1, 1, "sprite-canvas-cell--gold");
501+
502+
await page.getByRole("button", { name: "Add Frame" }).click();
503+
await expect(page.locator("[data-sprites-frame-card]")).toHaveCount(2);
504+
await expect(page.locator("[data-sprites-frame-status]")).toContainText("Frame 2 is selected");
505+
await expectCenterAndPreviewEmpty(page, 1, 1);
506+
507+
await page.getByRole("button", { name: "Blue editor color" }).click();
508+
await page.getByRole("button", { name: "Pencil tool" }).click();
509+
await spriteCell(page, 2, 2).click();
510+
await expectCenterAndPreviewPainted(page, 2, 2, "sprite-canvas-cell--blue");
511+
512+
await page.locator("[data-sprites-frame-card='0']").click();
513+
await expect(page.locator("[data-sprites-frame-status]")).toContainText("Frame 1 is selected");
514+
await expectCenterAndPreviewPainted(page, 1, 1, "sprite-canvas-cell--gold");
515+
await expectCenterAndPreviewEmpty(page, 2, 2);
516+
517+
await page.locator("[data-sprites-frame-card='1']").click();
518+
await expect(page.locator("[data-sprites-frame-status]")).toContainText("Frame 2 is selected");
519+
await expectCenterAndPreviewPainted(page, 2, 2, "sprite-canvas-cell--blue");
520+
521+
await page.getByRole("button", { name: "Duplicate Frame" }).click();
522+
await expect(page.locator("[data-sprites-frame-card]")).toHaveCount(3);
523+
await expect(page.locator("[data-sprites-frame-status]")).toContainText("Frame 3 is selected");
524+
await expectCenterAndPreviewPainted(page, 2, 2, "sprite-canvas-cell--blue");
525+
526+
await page.getByRole("button", { name: "Delete Frame" }).click();
527+
await expect(page.locator("[data-sprites-frame-card]")).toHaveCount(2);
528+
await expect(page.locator("[data-sprites-frame-status]")).toContainText("Frame 2 is selected");
529+
await expectCenterAndPreviewPainted(page, 2, 2, "sprite-canvas-cell--blue");
530+
531+
expect(failures.failedRequests).toEqual([]);
532+
expect(failures.pageErrors).toEqual([]);
533+
expect(failures.consoleErrors).toEqual([]);
534+
} finally {
535+
await server.close();
536+
}
537+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# PR_26179_CHARLIE_035-sprites-frame-editing
2+
3+
Team: CHARLIE
4+
5+
Mode: Batch governance stacked feature workflow
6+
7+
Base branch: PR_26179_CHARLIE_034-sprites-frame-strip
8+
9+
Branch: PR_26179_CHARLIE_035-sprites-frame-editing
10+
11+
## Summary
12+
13+
Added unsaved page-session frame editing for Sprite Creator. Creators can add, duplicate, select, and delete frames in the frame strip. Each frame keeps its own draft pixel map in page-session state only. No persistence, database, API, schema, or product-data source-of-truth behavior was introduced.
14+
15+
## Branch Validation
16+
17+
PASS
18+
19+
- Built from `PR_26179_CHARLIE_034-sprites-frame-strip`.
20+
- Scope stayed limited to unsaved frame editing.
21+
- No DB/API/schema changes.
22+
- No saved product data or authoritative browser-owned product data added.
23+
- No start_of_day files changed.
24+
- ZIP package created at the requested path.
25+
26+
## Requirement Checklist
27+
28+
PASS - Add Frame control added.
29+
30+
PASS - Duplicate Frame control added.
31+
32+
PASS - Delete Frame control added and disabled when only one frame exists.
33+
34+
PASS - Frame selection swaps the center canvas and preview to the selected frame.
35+
36+
PASS - Each frame keeps separate unsaved page-session pixels.
37+
38+
PASS - No persistence added.
39+
40+
PASS - Targeted Playwright coverage updated.
41+
42+
## Validation Lane
43+
44+
PASS - `node --check assets/toolbox/sprites/js/index.js`
45+
46+
PASS - `node --check dev/tests/playwright/tools/SpritesToolShell.spec.mjs`
47+
48+
PASS - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs`
49+
50+
PASS - Runtime guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files.
51+
52+
PASS - `npx playwright test dev/tests/playwright/tools/SpritesToolShell.spec.mjs --workers=1 --reporter=list`
53+
54+
## Manual Validation Notes
55+
56+
1. Open `toolbox/sprites/index.html`.
57+
2. Draw on Frame 1.
58+
3. Click Add Frame and confirm Frame 2 is empty.
59+
4. Draw different pixels on Frame 2.
60+
5. Select Frame 1 and confirm its pixels return.
61+
6. Select Frame 2 and confirm its pixels return.
62+
7. Duplicate Frame 2 and confirm the duplicate keeps the current pixels.
63+
8. Delete the duplicate and confirm the strip returns to two unsaved frames.
64+
65+
## ZIP
66+
67+
`dev/workspace/zip/PR_26179_CHARLIE_035-sprites-frame-editing_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_034-sprites-frame-strip.md
3+
docs_build/dev/reports/PR_26179_CHARLIE_035-sprites-frame-editing.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)