PageSourceSearch

https://2026.fosscomm.gr/wp-content/themes/FOSSCOMM%202026%204.0/assets/mascot/js/machine.js

js fosscomm.gr collected 2026-10-02 07:33:39 UTC 13,842 bytes, 324 lines download raw bytes

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.