1/** 2 * The state machine: which animation is playing, and what replaces it. 3 * 4 * Rules live on the STATE, never on the entry. A state may hold several named 5 * entries â "fall mad", "fall sad" â and one is picked at random when the state 6 * is entered and kept for the whole run of it. They are two faces of one 7 * behaviour, so anything about timing or what comes next is asked of the state. 8 * 9 * Two kinds of state: 10 * ⢠driven â physics owns the exit (held ends when you let go, fall ends on 11 * impact). The loop count and `next` are both ignored. 12 * ⢠timed â ends once its animation has been round `loops` times, and 13 * hands over to `next`. 14 * 15 * A state with no art never stalls the machine. One that hands over resolves 16 * straight to its `next`; one that doesn't borrows Idle's animation for as long 17 * as it lasts. Between them, a mascot with ONLY Idle configured still drops in, 18 * lands, walks and can be thrown â he just wears the same animation throughout. 19 * Idle is therefore the single thing you have to fill in. 20 */ 21 22import { Animator } from './animator.js'; 23import { buildClips } from './sheet.js'; 24import { STATE_SHAPE } from './config.js'; 25 26/** 27 * States the physics enters and leaves on its own, so the clip finishing means 28 * nothing. Land is one of them: it starts on the first thud and holds through 29 * however many bounces follow, and only the body coming to rest ends it. 30 */ 31const DRIVEN = new Set(['walk', 'slide', 'jump', 'fall', 'held', 'land']); 32 33export class Machine { 34 constructor(config, view, onEnter) { 35 this.config = config; 36 this.view = view; 37 this.onEnter = onEnter || (() => {}); 38 this.clips = {}; // state key â clip[] 39 this.state = null; 40 this.clip = null; 41 this.animator = null; 42 this.loopsWanted = 1; // full passes this animation gets before it is done 43 this.pending = null; // a section reaction currently overriding 44 this.chain = 0; // depth of the pass-through walk in enter() 45 this.entry = null; // the entry behind the current clip 46 this.frameChanged = false; // the drawing changed on the last update() 47 this.frameAppeared = false; // â¦or came round again on a single-frame clip 48 } 49 50 /** Load every state's art up front. One await, then no I/O at runtime. */ 51 async load() { 52 const keys = Object.keys(this.config.states); 53 const built = await Promise.all( 54 keys.map((key) => buildClips(this.config.states[key].entries)) 55 ); 56 keys.forEach((key, i) => { this.clips[key] = built[i]; }); 57 } 58 59 rules(key) { 60 const state = this.config.states[key]; 61 return (state && state.rules) || {}; 62 } 63 64 /** The configured animations for a state, art aside. */ 65 entries(key) { 66 const state = this.config.states[key]; 67 return (state && state.entries) || []; 68 } 69 70 has(key) { 71 return !!(this.clips[key] && this.clips[key].length); 72 } 73 74 isDriven(key) { 75 return DRIVEN.has(key); 76 } 77 78 /** 79 * Bounciness for the frame ON SCREEN RIGHT NOW. 80 * 81 * Per frame rather than per animation, so a walk can give on the footfalls 82 * and stay rigid through the rest of the cycle. Falls back to the 83 * animation's own value, and then to 1, so an animation whose frames were 84 * never given individual values behaves exactly as it did before. 85 */ 86 get bounce() { 87 const entry = this.entry; 88 if (!entry) return 1; 89 const per = entry.bounces; 90 if (this.animator && Array.isArray(per) && per.length) { 91 const v = per[this.animator.cell]; 92 if (Number.isFinite(v)) return v; 93 } 94 return Number.isFinite(entry.bounce) ? entry.bounce : 1; 95 } 96 97 /** 98 * The pop on ARRIVING in this state â one moment, belonging to the animation 99 * rather than to any one of its frames. 100 * 101 * The animation's own field, at any frame count. A single-frame animation 102 * used to read its timeline box here instead, because the admin hid the 103 * animation-level field for those; it does not any more, so there is no 104 * special case left to make. Arriving and holding are two moments even when 105 * they are drawn the same. 106 */ 107 get entryBounce() { 108 const entry = this.entry; 109 if (!entry) return 1; 110 return Number.isFinite(entry.bounce) ? entry.bounce : 1; 111 } 112 113 /** Where a state hands over when it finishes. Wiring, not configuration. */ 114 nextOf(key) { 115 const shape = STATE_SHAPE[key]; 116 return (shape && shape.next) || ''; 117 } 118 119 /** 120 * Enter a state. 121 * 122 * `force` replays a state that is already current â a second landing should 123 * restart the Land animation, but the ordinary per-frame "should be idle" 124 * call must not restart Idle sixty times a second. 125 */ 126 enter(key, force = false) { 127 if (!force && this.state === key && !this.pending) return; 128 if (!this.config.states[key]) return; 129 130 // No art of its own. Two different right answers, and which one applies 131 // falls out of the wiring rather than a list to keep in step: 132 // 133 // ⢠a state that hands over (Land â After-land â Idle) is TRANSPARENT. 134 // Standing in for it would mean playing an idle loop for the length 135 // of a landing, which is worse than not animating the landing. 136 // ⢠a state that does NOT hand over (Fall, Walk, Held, Jump, Sleep) 137 // lasts until the physics says otherwise, so he has to look like 138 // something for as long as it holds. It borrows Idle. 139 //
140 // That is what lets a mascot with ONE configured animation work: he 141 // drops in, lands and stands there, all wearing Idle. 142 if (!this.has(key)) { 143 const next = this.nextOf(key); 144 // The guard is a chain length, not a visited set â a deliberate 145 // aâbâa pair should stop, not throw. 146 if (next && next !== key && this.chain < 8) { 147 this.chain++; 148 this.enter(next, force); 149 this.chain--; 150 return; 151 } 152 153 const stand = this.clips.idle; 154 if (key !== 'idle' && stand && stand.length) { 155 // The FIRST idle animation, not a random one: a borrowed look 156 // should be stable, or he would reshuffle every time the physics 157 // flicked him between walking and falling. 158 const clip = stand[0]; 159 this.playFrom(key, clip, this.entries('idle').find((e) => e.png === clip.url) || null); 160 return; 161 } 162 163 // Nothing at all to draw. Record the state anyway, so the 164 // physics-driven logic still knows what he is notionally doing. 165 this.state = key; 166 this.pending = null; 167 this.view.setState(key); 168 return; 169 } 170 171 const options = this.clips[key]; 172 // One pick for the whole run of the state. 173 const clip = options[Math.floor(Math.random() * options.length)]; 174 this.playFrom(key, clip, this.entries(key).find((e) => e.png === clip.url) || null); 175 } 176 177 /** 178 * Enter a state playing a CHOSEN animation rather than a random pick. 179 * 180 * The Random pool needs this: which animation plays was already settled by 181 * the per-animation chance roll, so re-rolling here would throw that away 182 * and could land on one whose own chance had just failed. 183 */ 184 playFrom(key, clip, entry) { 185 const rules = this.rules(key); 186 const options = this.clips[key] || []; 187 188 this.state = key; 189 this.pending = null; 190 this.clip = clip; 191 this.entry = entry || null; 192 193 // How many full passes this animation gets before the state is done 194 // with it. From the ANIMATION, not the state: a state can hold several 195 // sheets and they are not the same length, so a single duration on the 196 // state could never be a whole number of passes for all of them. 197 this.loopsWanted = Math.max(1, (this.entry && this.entry.loops) || 1); 198 199 // The animator has to be told to repeat if we want more than one pass 200 // out of it â a one-shot clip stops on its last cell and never comes 201 // round again. Continuous states repeat regardless; the count is what 202 // decides when to move on, not the animator. 203 const repeats = !!STATE_SHAPE[key].loops || this.loopsWanted > 1; 204 this.animator = new Animator(clip, repeats); 205 void options; 206 void rules; 207 208 this.view.setState(key); 209 this.view.paint(clip, this.animator.cell); 210 this.onEnter(key, clip, this.entry); 211 } 212 213 /** 214 * Play a one-shot animation over the top of whatever is current â a section 215 * reaction. Returns to the state underneath when its time is up. 216 */ 217 play(entryClip, entry) { 218 if (!entryClip) return; 219 this.pending = { returnTo: this.state || 'idle' }; 220 this.clip = entryClip; 221 this.entry = entry || null; 222 // Counted in passes, exactly like every other animation. A reaction used 223 // to carry its own duration in ms, which had all the same problems as 224 // the state-level one it outlived: kept in step by hand, and able to stop 225 // the animation part-way through. 226 this.loopsWanted = Math.max(1, (this.entry && this.entry.loops) || 1); 227 this.animator = new Animator(entryClip, true); 228 this.view.paint(entryClip, this.animator.cell); 229 this.onEnter(this.state, entryClip, this.entry); 230 } 231 232 /** True while a section reaction is on screen. */ 233 get overriding() { 234 return !!this.pending; 235 } 236 237 /** 238 * Cut a section reaction short. Returns the state to go back to, or null if 239 * nothing was overriding. 240 */ 241 cancelOverride() { 242 if (!this.pending) return null; 243 const back = this.pending.returnTo; 244 this.pending = null; 245 return back; 246 } 247 248 /** 249 * Advance the current animation. 250 * Returns the state key to move to, or null to stay put. 251 */ 252 update(dt) { 253 this.frameChanged = false;
254 this.frameAppeared = false; 255 if (!this.animator || !this.clip) return null; 256 257 // Whether the drawing actually changed this frame, not merely that time 258 // passed. main.js hangs the per-frame bounce off it, and a bounce that 259 // fired every tick rather than every frame would just be a blur. 260 const was = this.animator.cell; 261 const wasLoops = this.animator.loops; 262 const cell = this.animator.update(dt); 263 this.frameChanged = cell !== was; 264 265 // "This drawing has just come up", which is not quite the same question. 266 // 267 // A SINGLE-FRAME animation never changes cell â there is only one â so 268 // `frameChanged` is false for its whole life and anything hung off it 269 // fires exactly never. That is why a one-frame sheet with a bounciness 270 // set on it sat perfectly still on the site while the preview, which 271 // pops once per pass, bounced it. Counting a completed pass as the frame 272 // appearing again makes the two agree, and makes a held pose with a 273 // 1500ms frame give once every 1500ms rather than once ever. 274 this.frameAppeared = this.frameChanged 275 || (this.clip.count === 1 && this.animator.loops > wasLoops); 276 277 this.view.paint(this.clip, cell); 278 279 // A section reaction, counted in passes like everything else. It also 280 // ends the moment you leave the section, which sections.js handles by 281 // calling cancelOverride() â so this is the "you stayed long enough to 282 // see it through" half. 283 if (this.pending) { 284 if (this.animator.loops >= this.loopsWanted) { 285 const back = this.pending.returnTo; 286 this.pending = null; 287 return back; 288 } 289 return null; 290 } 291 292 // Counted in PASSES of the animation, never in milliseconds. 293 // 294 // A count can only ever end the animation where the animation ends. The 295 // duration it replaced could not: any value that was not an exact 296 // multiple of the timeline stopped it part-way through a frame, and it 297 // went stale the moment anybody retimed one. 298 // 299 // What "done" means still depends on how the state ends: 300 // 301 // ⢠a state the physics owns (Fall, Held, Land, Walkâ¦) cannot be ended 302 // by counting â he is airborne until he lands, however many passes 303 // that takes. There, finishing the count just picks another 304 // animation from the pool, so a state with several sheets cycles 305 // through them. 306 // ⢠a one-shot (Random, After-land, the sleep transitions) hands over 307 // to its next state, which is what actually ends it. 308 if (this.animator.loops >= this.loopsWanted) { 309 const handsOver = !this.isDriven(this.state) && this.nextOf(this.state); 310 if (handsOver) return this.nextOf(this.state); 311 312 if ((this.clips[this.state] || []).length > 1) this.enter(this.state, true); 313 else this.animator.loops = 0; // go round again 314 return null; 315 } 316 317 if (this.isDriven(this.state)) return null; // physics decides 318 319 // A clip with no frames to count â fall back to the animator's own idea 320 // of having finished. 321 if (this.animator.done) return this.nextOf(this.state) || 'idle'; 322 return null; 323 } 324}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.