@@ -8,176 +8,207 @@ interface AnimationEditorProps {
88 onChange : ( menu : VisualJavaMenu ) => void ;
99}
1010
11- /** Ticks per frame, as the plugin counts them. */
11+ /** Ticks per frame, as the plugin counts them. A tick is 50ms. */
1212const DEFAULT_INTERVAL = 2 ;
13+ const TICK_MS = 50 ;
1314
15+ /**
16+ * The animation strip: one card per frame, played back at the configured rate
17+ * so the sequence can be judged without opening the game.
18+ */
1419export function AnimationEditor ( { menu, serverVersion, onChange } : AnimationEditorProps ) {
15- const entries = Object . entries ( menu . animations ) ;
20+ const names = Object . keys ( menu . animations ) ;
21+ const [ selected , setSelected ] = useState < string | null > ( names [ 0 ] ?? null ) ;
22+ const [ playing , setPlaying ] = useState ( false ) ;
23+ const [ frameIndex , setFrameIndex ] = useState ( 0 ) ;
1624
17- const update = ( key : string , animation : VisualAnimation ) : void =>
18- onChange ( { ... menu , animations : { ... menu . animations , [ key ] : animation } } ) ;
25+ const active = selected === null ? undefined : menu . animations [ selected ] ;
26+ const frames = active === undefined ? [ ] : Object . entries ( active . frames ) ;
1927
20- return (
21- < section className = "item-editor-section" >
22- < header className = "section-heading" >
23- < h3 className = "section-heading" > Animations</ h3 >
24- < button
25- type = "button"
26- onClick = { ( ) =>
27- onChange ( {
28- ...menu ,
29- animations : {
30- ...menu . animations ,
31- [ nextKey ( menu . animations ) ] : { interval : DEFAULT_INTERVAL , frames : { } } ,
32- } ,
33- } )
34- }
35- className = "btn btn-secondary btn-sm"
36- >
37- Add animation
38- </ button >
39- </ header >
40-
41- { entries . length === 0 && < p className = "empty-note" > This menu has no animated items.</ p > }
42-
43- { entries . map ( ( [ key , animation ] ) => (
44- < article key = { key } className = "entry-card" >
45- < header className = "entry-card-header" >
46- < span > { key } </ span >
47- < FramePreview animation = { animation } serverVersion = { serverVersion } />
48- < button
49- type = "button"
50- onClick = { ( ) => {
51- const animations = { ...menu . animations } ;
52- delete animations [ key ] ;
53- onChange ( { ...menu , animations } ) ;
54- } }
55- className = "btn btn-danger btn-sm"
56- >
57- Remove
28+ useEffect ( ( ) => {
29+ if ( ! playing || active === undefined || frames . length < 2 ) {
30+ return ;
31+ }
32+
33+ const period = Math . max ( 1 , active . interval ) * TICK_MS ;
34+ const timer = setInterval ( ( ) => setFrameIndex ( current => ( current + 1 ) % frames . length ) , period ) ;
35+
36+ return ( ) => clearInterval ( timer ) ;
37+ } , [ playing , active , frames . length ] ) ;
38+
39+ if ( names . length === 0 ) {
40+ return (
41+ < div className = "animation-timeline-container" >
42+ < div className = "timeline-header" >
43+ < span className = "timeline-title" > ⚡ Animation Timeline (Individual Slot)</ span >
44+ < div className = "timeline-controls" >
45+ < button type = "button" className = "btn btn-secondary btn-sm" onClick = { ( ) => addAnimation ( menu , onChange ) } >
46+ + Animation
5847 </ button >
59- </ header >
48+ </ div >
49+ </ div >
50+ < div className = "timeline-frames" >
51+ < span className = "empty-note" > This menu has no animated items.</ span >
52+ </ div >
53+ </ div >
54+ ) ;
55+ }
56+
57+ const update = ( animation : VisualAnimation ) : void => {
58+ if ( selected !== null ) {
59+ onChange ( { ...menu , animations : { ...menu . animations , [ selected ] : animation } } ) ;
60+ }
61+ } ;
62+
63+ return (
64+ < div className = "animation-timeline-container" >
65+ < div className = "timeline-header" >
66+ < span className = "timeline-title" > ⚡ Animation Timeline (Individual Slot)</ span >
67+
68+ < div className = "timeline-controls" >
69+ < select
70+ className = "inline-input"
71+ style = { { width : 'auto' } }
72+ value = { selected ?? '' }
73+ onChange = { event => {
74+ setSelected ( event . target . value ) ;
75+ setFrameIndex ( 0 ) ;
76+ } }
77+ aria-label = "Animation"
78+ >
79+ { names . map ( name => (
80+ < option key = { name } value = { name } >
81+ { name }
82+ </ option >
83+ ) ) }
84+ </ select >
85+
86+ < button type = "button" className = "btn btn-secondary btn-sm" onClick = { ( ) => setPlaying ( true ) } title = "Play" >
87+ ▶️
88+ </ button >
89+ < button type = "button" className = "btn btn-secondary btn-sm" onClick = { ( ) => setPlaying ( false ) } title = "Stop" >
90+ ⏹️
91+ </ button >
92+ < button
93+ type = "button"
94+ className = "btn btn-secondary btn-sm"
95+ title = "Add Frame"
96+ onClick = { ( ) =>
97+ active !== undefined &&
98+ update ( {
99+ ...active ,
100+ frames : {
101+ ...active . frames ,
102+ [ `frame${ frames . length + 1 } ` ] : lastFrame ( frames ) ?? { material : 'STONE' , amount : 1 } ,
103+ } ,
104+ } )
105+ }
106+ >
107+ + Frame
108+ </ button >
109+ < button type = "button" className = "btn btn-secondary btn-sm" onClick = { ( ) => addAnimation ( menu , onChange ) } title = "Add animation" >
110+ + Animation
111+ </ button >
60112
61- < label >
62- Interval (ticks per frame)
113+ < span className = "timeline-interval" >
114+ Interval: { ' ' }
63115 < input
64116 type = "number"
65117 min = { 1 }
66- value = { animation . interval }
67- onChange = { event => update ( key , { ...animation , interval : Number ( event . target . value ) } ) }
68- className = "inline-input" style = { { width : '90px' } }
69- />
70- </ label >
71-
72- < Frames
73- animation = { animation }
74- serverVersion = { serverVersion }
75- onChange = { next => update ( key , next ) }
76- />
77- </ article >
78- ) ) }
79- </ section >
118+ max = { 100 }
119+ style = { { width : '60px' } }
120+ value = { active ?. interval ?? DEFAULT_INTERVAL }
121+ onChange = { event => active !== undefined && update ( { ...active , interval : Number ( event . target . value ) } ) }
122+ aria-label = "Interval in ticks"
123+ /> { ' ' }
124+ ticks
125+ </ span >
126+ </ div >
127+ </ div >
128+
129+ < div className = "timeline-frames" >
130+ { frames . map ( ( [ key , frame ] , index ) => (
131+ < div
132+ key = { key }
133+ className = { `timeline-frame${ playing && index === frameIndex ? ' active' : '' } ` }
134+ title = { `Frame ${ index + 1 } : ${ frame . material } ` }
135+ role = "button"
136+ tabIndex = { 0 }
137+ onClick = { ( ) => setFrameIndex ( index ) }
138+ onKeyDown = { event => event . key === 'Enter' && setFrameIndex ( index ) }
139+ >
140+ < div className = "timeline-frame-number" > { index + 1 } </ div >
141+ < div className = "timeline-frame-preview" >
142+ < ItemIcon item = { frame } serverVersion = { serverVersion } variant = "canvas" />
143+ </ div >
144+ </ div >
145+ ) ) }
146+ </ div >
147+
148+ { frames [ frameIndex ] !== undefined && active !== undefined && (
149+ < FrameFields
150+ frame = { frames [ frameIndex ] [ 1 ] }
151+ onChange = { frame =>
152+ update ( { ...active , frames : { ...active . frames , [ frames [ frameIndex ] [ 0 ] ] : frame } } )
153+ }
154+ onRemove = { ( ) => {
155+ const next = { ...active . frames } ;
156+ delete next [ frames [ frameIndex ] [ 0 ] ] ;
157+ update ( { ...active , frames : next } ) ;
158+ setFrameIndex ( 0 ) ;
159+ } }
160+ />
161+ ) }
162+ </ div >
80163 ) ;
81164}
82165
83- function Frames ( {
84- animation,
85- serverVersion,
166+ function FrameFields ( {
167+ frame,
86168 onChange,
169+ onRemove,
87170} : {
88- animation : VisualAnimation ;
89- serverVersion : string | null ;
90- onChange : ( animation : VisualAnimation ) => void ;
171+ frame : VisualItem ;
172+ onChange : ( frame : VisualItem ) => void ;
173+ onRemove : ( ) => void ;
91174} ) {
92- const frames = Object . entries ( animation . frames ) ;
93-
94- const setFrame = ( key : string , frame : VisualItem ) : void =>
95- onChange ( { ...animation , frames : { ...animation . frames , [ key ] : frame } } ) ;
96-
97175 return (
98- < div className = "editor-field" >
99- { frames . map ( ( [ key , frame ] ) => (
100- < div key = { key } className = "entry-card-header" >
101- < ItemIcon item = { frame } serverVersion = { serverVersion } className = "size-6" />
102- < input
103- value = { frame . material }
104- onChange = { event => setFrame ( key , { ...frame , material : event . target . value . toUpperCase ( ) } ) }
105- aria-label = { `${ key } material` }
106- className = "inline-input"
107- />
108- < input
109- value = { typeof frame . name === 'string' ? frame . name : '' }
110- onChange = { event => setFrame ( key , { ...frame , name : event . target . value } ) }
111- placeholder = "name"
112- aria-label = { `${ key } name` }
113- className = "inline-input"
114- />
115- < button
116- type = "button"
117- onClick = { ( ) => {
118- const next = { ...animation . frames } ;
119- delete next [ key ] ;
120- onChange ( { ...animation , frames : next } ) ;
121- } }
122- aria-label = { `Remove ${ key } ` }
123- className = "tab-close"
124- >
125- x
126- </ button >
127- </ div >
128- ) ) }
129-
130- < button
131- type = "button"
132- onClick = { ( ) =>
133- onChange ( {
134- ...animation ,
135- frames : {
136- ...animation . frames ,
137- [ `frame${ frames . length + 1 } ` ] : { material : 'STONE' , amount : 1 , name : '&f' } ,
138- } ,
139- } )
140- }
141- className = "btn btn-secondary btn-sm"
142- >
143- Add frame
176+ < div className = "editor-row" >
177+ < input
178+ className = "inline-input"
179+ value = { frame . material }
180+ onChange = { event => onChange ( { ...frame , material : event . target . value . toUpperCase ( ) } ) }
181+ aria-label = "Frame material"
182+ />
183+ < input
184+ className = "inline-input"
185+ value = { typeof frame . name === 'string' ? frame . name : '' }
186+ onChange = { event => onChange ( { ...frame , name : event . target . value } ) }
187+ placeholder = "name"
188+ aria-label = "Frame name"
189+ />
190+ < button type = "button" className = "btn btn-danger btn-sm" onClick = { onRemove } >
191+ Remove frame
144192 </ button >
145193 </ div >
146194 ) ;
147195}
148196
149- /** Cycles the frames at the configured rate so the animation can be judged here. */
150- function FramePreview ( { animation, serverVersion } : { animation : VisualAnimation ; serverVersion : string | null } ) {
151- const frames = Object . values ( animation . frames ) ;
152- const [ index , setIndex ] = useState ( 0 ) ;
153-
154- useEffect ( ( ) => {
155- if ( frames . length < 2 ) {
156- return ;
157- }
158-
159- // A Minecraft tick is 50ms.
160- const period = Math . max ( 1 , animation . interval ) * 50 ;
161- const timer = setInterval ( ( ) => setIndex ( current => ( current + 1 ) % frames . length ) , period ) ;
162-
163- return ( ) => clearInterval ( timer ) ;
164- } , [ animation . interval , frames . length ] ) ;
165-
166- const frame = frames [ index % Math . max ( 1 , frames . length ) ] ;
167-
168- if ( frame === undefined ) {
169- return null ;
170- }
197+ function lastFrame ( frames : [ string , VisualItem ] [ ] ) : VisualItem | undefined {
198+ const entry = frames [ frames . length - 1 ] ;
171199
172- return < ItemIcon item = { frame } serverVersion = { serverVersion } className = "size-6" /> ;
200+ return entry === undefined ? undefined : { ... entry [ 1 ] } ;
173201}
174202
175- function nextKey ( animations : Record < string , unknown > ) : string {
176- let index = Object . keys ( animations ) . length + 1 ;
203+ function addAnimation ( menu : VisualJavaMenu , onChange : ( menu : VisualJavaMenu ) => void ) : void {
204+ let index = Object . keys ( menu . animations ) . length + 1 ;
177205
178- while ( animations [ `animation${ index } ` ] !== undefined ) {
206+ while ( menu . animations [ `animation${ index } ` ] !== undefined ) {
179207 index ++ ;
180208 }
181209
182- return `animation${ index } ` ;
210+ onChange ( {
211+ ...menu ,
212+ animations : { ...menu . animations , [ `animation${ index } ` ] : { interval : DEFAULT_INTERVAL , frames : { } } } ,
213+ } ) ;
183214}
0 commit comments