'use strict'; // VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in // under gravity, squashes on impact (convex cushion bulging out the left/right edges // + a touch of skew), and springs back up — looping. A light "glint" is caught at the // moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans // outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json. const fs = require('fs'); const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8')); // ---- canvas / timing ------------------------------------------------------- const W = 96, H = 96, cx = 48; const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster) // keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell) const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28; // ---- geometry -------------------------------------------------------------- const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce // ---- palette --------------------------------------------------------------- const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255]; const FACE = col('#D9B978'); // wood face (flat) const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding) const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg const BLACK = col('#1A1A1A'); const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t); // ---- property / easing helpers --------------------------------------------- const still = v => ({ a: 0, k: v }); const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall) const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex) const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion) function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key return { a: 1, k: samples.map((s, idx) => { const kf = { t: s.t, s: s.v }; if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; } return kf; }) }; } const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e }))); // ---- shape helpers --------------------------------------------------------- const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) }); const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm }); const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 }); const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 }); function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group const sc = hpx / gd.bbox.h; const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2; const shapes = gd.contours.map(ct => ({ ty: 'sh', d: 1, ks: still({ i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]), v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true, }), })); return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm); } // Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point // per straight edge, so a smooth interpolant reproduces it cleanly. function arc(ccx, ccy, a0, a1, n) { const out = []; for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); } return out; } const REST = (() => { const NC = 7, yi = HH - CR, xi = HW - CR; const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ]; const pts = []; for (let k = 0; k < 4; k++) { pts.push(...C[k]); const a = C[k][NC-1], b = C[(k+1)%4][0]; pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight) } return pts; })(); // The whole left/right contour (corners + side) bows out by one smooth barrel profile; // Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the // corners follow the cushion and the cushion melts into the corners, bulged or not. function facePath(b) { const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay const n = V.length, I = [], O = []; for (let k = 0; k < n; k++) { const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n]; let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours) const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl; const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]); } return { i: I, o: O, v: V, c: true }; } const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => { const kf = { t: s.t, s: [facePath(s.b)] }; if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; } return kf; }) }); // ---- animation tracks ------------------------------------------------------ // bottom-centre screen-y (the tile rests/squashes on its bottom edge) const posY = anim([ { t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell) { t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor { t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs { t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start ]); // scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact const scl = anim([ { t: 0, v: [100, 100, 100], e: ES }, { t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast { t: T_HIT, v: [104, 95, 100], e: ES }, // touch down { t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче) { t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing { t: OP, v: [100, 100, 100] }, ]); // a touch of skew through the squash (organic deform), back to 0 const skw = anim([ { t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] }, ]); // left/right cushion bulge: 0 -> gentle peak -> 0 (never inward) const bulge = facePathKeys([ { t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 }, ]); // glint: face + glyph catch the light at the bounce const faceCol = animColor([ { t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE }, ]); const glyphCol = animColor([ { t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK }, ]); // ---- layer (face + glyphs share the bounce/squash transform) --------------- const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face'); const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner const tile = { ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1, ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) }, ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0, }; const root = { v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0, assets: [], layers: [ tile ], markers: [], }; const out = process.argv[2] || 'erudit.json'; const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v); fs.writeFileSync(out, JSON.stringify(root, round)); console.log('wrote', out, fs.statSync(out).size, 'bytes');