11const DEFAULT_GRID_SIZE = 16 ;
2+ const DEFAULT_FRAME_DURATION_MS = 120 ;
23const SUPPORTED_GRID_SIZES = Object . freeze ( [ 16 , 32 ] ) ;
34const SUPPORTED_ZOOM_LEVELS = Object . freeze ( [ 1 , 2 , 4 ] ) ;
45const 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 ) ;
1525const 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+
5177function 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
145172function 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
448478function 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
459561function 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+
582704function wireHistoryActions ( ) {
583705 const undoButton = document . querySelector ( "[data-sprites-undo]" ) ;
584706 if ( undoButton ) {
@@ -618,6 +740,7 @@ wireGridControls();
618740wireDrawingTools ( ) ;
619741wirePaletteButtons ( ) ;
620742wireCanvasActions ( ) ;
743+ wireFrameActions ( ) ;
621744wireHistoryActions ( ) ;
622745wireZoomControls ( ) ;
623746wireExportButton ( ) ;
0 commit comments