1// Sprite showcase â a self-contained animated sprite that walks around a 2// bordered box on a /product/<slug> page for a SPRITE_PACK listing. 3// 4// The page is WS-served, so the showcase box appears in the DOM *after* the 5// renderer has loaded its scripts. This module follows the same self-init 6// pattern as assets/cart_persist.js and the FlexLayout map observer in 7// assets/js/tileset_clientside.js: a global `_initialized` guard plus a 8// MutationObserver that scans for `.sprite-showcase[data-frames]` containers 9// added to the tree, initializing each box exactly once (a per-element dataset 10// flag). 11// 12// The server renders: 13// <div class="sprite-showcase" 14// data-frames='{"walking/south/000":"/sprites/<slug>/walking/south/000.png", ...}' 15// data-base="/sprites/<slug>"> 16// where data-frames is the EXACT frames_for() map â so the JS only ever paints 17// frames that EXIST (no 404 probing). 18// 19// ANTI-COPY: the sprite is a CSS background-image on a <div> (never an <img>), 20// the box disables the context menu, drag, and selection, and the random walk 21// keeps the sprite moving â so a right-click "Save image" or a drag grabs 22// nothing useful and a clean screenshot is hard to land. 23// 24// prefers-reduced-motion: collapse to a single centered static idle frame; no 25// movement, no animation loop. 26(function () { 27 if (window._spriteShowcaseInitialized) return; 28 window._spriteShowcaseInitialized = true; 29 30 var REDUCED_MOTION = 31 window.matchMedia && 32 window.matchMedia("(prefers-reduced-motion: reduce)").matches; 33 34 // Canonical compass directions, in clockwise order starting at north, with 35 // their unit movement vectors (y grows downward in screen space). 36 var DIRS = [ 37 { name: "north", dx: 0, dy: -1 }, 38 { name: "northeast", dx: 1, dy: -1 }, 39 { name: "east", dx: 1, dy: 0 }, 40 { name: "southeast", dx: 1, dy: 1 }, 41 { name: "south", dx: 0, dy: 1 }, 42 { name: "southwest", dx: -1, dy: 1 }, 43 { name: "west", dx: -1, dy: 0 }, 44 { name: "northwest", dx: -1, dy: -1 }, 45 ]; 46 47 var SPRITE_PX = 64; // on-screen sprite footprint 48 var FPS = 8; // animation cadence 49 var SPEED = 0.7; // px per ms â gentle stroll 50 51 // Parse the frames_for map into { anim: { dir: [url, url, ...] } }, with 52 // each direction's frames sorted by zero-padded index. 53 function parseFrames(raw) { 54 var map = {}; 55 var data; 56 try { 57 data = JSON.parse(raw); 58 } catch (e) { 59 return map; 60 } 61 var indexByKey = {}; 62 Object.keys(data || {}).forEach(function (key) { 63 // key shape: "{anim}/{dir}/{idx:03d}" 64 var parts = key.split("/"); 65 if (parts.length !== 3) return; 66 var anim = parts[0]; 67 var dir = parts[1]; 68 var idx = parseInt(parts[2], 10); 69 if (isNaN(idx)) return; 70 map[anim] = map[anim] || {}; 71 map[anim][dir] = map[anim][dir] || []; 72 var entry = { idx: idx, url: data[key] }; 73 map[anim][dir].push(entry); 74 indexByKey[anim + "/" + dir] = true; 75 }); 76 // Sort each direction's frames by index and flatten to a url list. 77 Object.keys(map).forEach(function (anim) { 78 Object.keys(map[anim]).forEach(function (dir) { 79 map[anim][dir].sort(function (a, b) { 80 return a.idx - b.idx; 81 }); 82 map[anim][dir] = map[anim][dir].map(function (e) { 83 return e.url; 84 }); 85 }); 86 }); 87 return map; 88 } 89 90 // Pick the animation to show off: prefer a movement anim, else idle, else 91 // the first anim the pack actually has. 92 function pickAnim(frames) { 93 var anims = Object.keys(frames); 94 if (!anims.length) return null; 95 var prefer = ["walking", "walk", "running", "run"]; 96 for (var i = 0; i < prefer.length; i++) { 97 if (frames[prefer[i]]) return prefer[i]; 98 } 99 if (frames.idle) return "idle"; 100 // Any non-idle anim reads as motion-capable; else fall to the first. 101 var nonIdle = anims.filter(function (a) { 102 return a !== "idle"; 103 }); 104 return nonIdle.length ? nonIdle[0] : anims[0]; 105 } 106 107 // Directions this animation actually carries, intersected with the compass 108 // order so the facing-vector lookup only ever picks a direction that exists. 109 function availableDirs(animFrames) { 110 return DIRS.filter(function (d) { 111 return animFrames[d.name] && animFrames[d.name].length; 112 }); 113 } 114 115 // Nearest available direction to a movement vector (dot product over the 116 // unit vectors). Falls back to the first available when the vector is zero. 117 function dirForVector(dirs, dx, dy) { 118 if (dx === 0 && dy === 0) return dirs[0]; 119 var best = dirs[0]; 120 var bestDot = -Infinity; 121 for (var i = 0; i < dirs.length; i++) { 122 var d = dirs[i]; 123 var dot = d.dx * dx + d.dy * dy; 124 // normalize the candidate vector so diagonals don't out-vote cardinals 125 var mag = Math.sqrt(d.dx * d.dx + d.dy * d.dy) || 1; 126 dot = dot / mag; 127 if (dot > bestDot) { 128 bestDot = dot; 129 best = d; 130 } 131 } 132 return best; 133 } 134 135 function staticFrame(box, frames) { 136 // A single centered, non-moving frame (reduced-motion, idle-only, or a 137 // single-direction pack). Prefer idle/first-direction frame 0. 138 var anim = frames.idle ? "idle" : pickAnim(frames); 139 if (!anim) return; 140 var dirs = availableDirs(frames[anim]); 141 var dir = dirs.length ? dirs[0].name : Object.keys(frames[anim])[0]; 142 var url = frames[anim][dir] && frames[anim][dir][0]; 143 if (!url) return; 144 var sprite = makeSpriteEl(url); 145 var rect = box.getBoundingClientRect(); 146 var bw = rect.width || box.offsetWidth || 320; 147 var bh = rect.height || box.offsetHeight || 320; 148 sprite.style.left = Math.round((bw - SPRITE_PX) / 2) + "px"; 149 sprite.style.top = Math.round((bh - SPRITE_PX) / 2) + "px"; 150 box.appendChild(sprite); 151 } 152 153 function makeSpriteEl(url) { 154 var sprite = document.createElement("div"); 155 sprite.className = "sprite-showcase__sprite"; 156 sprite.setAttribute("draggable", "false"); 157 sprite.style.position = "absolute"; 158 sprite.style.width = SPRITE_PX + "px"; 159 sprite.style.height = SPRITE_PX + "px"; 160 sprite.style.backgroundImage = "url('" + url + "')"; 161 sprite.style.backgroundSize = "contain"; 162 sprite.style.backgroundRepeat = "no-repeat"; 163 sprite.style.backgroundPosition = "center"; 164 sprite.style.imageRendering = "pixelated"; 165 sprite.style.pointerEvents = "none"; 166 sprite.style.userSelect = "none"; 167 sprite.style.webkitUserDrag = "none"; 168 sprite.style.willChange = "left, top"; 169 return sprite; 170 } 171 172 function startWander(box, frames) { 173 var anim = pickAnim(frames); 174 if (!anim) return; 175 var dirs = availableDirs(frames[anim]); 176 177 // Single-direction (or no usable direction) packs don't move â they'd 178 // turn-clamp against an empty direction set. Play the one direction static. 179 if (dirs.length <= 1) { 180 staticFrame(box, frames); 181 return; 182 } 183 184 var rect = box.getBoundingClientRect(); 185 var bw = rect.width || box.offsetWidth || 320; 186 var bh = rect.height || box.offsetHeight || 320; 187 var maxX = Math.max(0, bw - SPRITE_PX); 188 var maxY = Math.max(0, bh - SPRITE_PX); 189 190 var sprite = makeSpriteEl(""); 191 box.appendChild(sprite); 192 193 var pos = { x: maxX / 2, y: maxY / 2 }; 194 var heading = dirs[Math.floor(Math.random() * dirs.length)]; 195 var facing = heading; 196 var frameIdx = 0; 197 var lastFrameAt = 0; 198 var lastMoveAt = 0; 199 var pauseUntil = 0; 200 201 function pickNewHeading() { 202 // Choose a fresh available direction (allow repeats rarely); occasional 203 // brief pause to read as "showing itself off". 204 heading = dirs[Math.floor(Math.random() * dirs.length)]; 205 facing = heading; 206 if (Math.random() < 0.25) { 207 pauseUntil = performance.now() + 400 + Math.random() * 900; 208 } 209 } 210 211 function paint() { 212 var seq = frames[anim][facing.name] || []; 213 if (!seq.length) { 214 // Facing a direction this anim lacks â snap to the nearest it has. 215 facing = dirForVector(dirs, facing.dx, facing.dy); 216 seq = frames[anim][facing.name] || []; 217 } 218 if (seq.length) { 219 var url = seq[frameIdx % seq.length]; 220 sprite.style.backgroundImage = "url('" + url + "')"; 221 } 222 } 223 224 function tick(now) { 225 if (!box.isConnected) return; // box left the DOM â stop the loop 226 var dtMove = lastMoveAt ? now - lastMoveAt : 0; 227 lastMoveAt = now; 228 229 var moving = now >= pauseUntil; 230 if (moving) { 231 pos.x += heading.dx * SPEED * dtMove; 232 pos.y += heading.dy * SPEED * dtMove; 233 234 // Clamp to the box and turn on contact with an edge. 235 var bounced = false;
236 if (pos.x <= 0) { 237 pos.x = 0; 238 bounced = true; 239 } else if (pos.x >= maxX) { 240 pos.x = maxX; 241 bounced = true; 242 } 243 if (pos.y <= 0) { 244 pos.y = 0; 245 bounced = true; 246 } else if (pos.y >= maxY) { 247 pos.y = maxY; 248 bounced = true; 249 } 250 if (bounced || Math.random() < 0.004) { 251 pickNewHeading(); 252 } 253 facing = dirForVector(dirs, heading.dx, heading.dy); 254 } 255 256 sprite.style.left = Math.round(pos.x) + "px"; 257 sprite.style.top = Math.round(pos.y) + "px"; 258 259 // Advance the walk-cycle frame at FPS (only while moving; a pause holds 260 // the current frame so it reads as standing still). 261 if (moving && now - lastFrameAt >= 1000 / FPS) { 262 frameIdx += 1; 263 lastFrameAt = now; 264 paint(); 265 } 266 requestAnimationFrame(tick); 267 } 268 269 paint(); 270 requestAnimationFrame(tick); 271 } 272 273 function initBox(box) { 274 if (box.dataset.showcaseInit === "1") return; 275 box.dataset.showcaseInit = "1"; 276 277 // Anti-copy hardening on the container. 278 box.oncontextmenu = function () { 279 return false; 280 }; 281 box.addEventListener("dragstart", function (e) { 282 e.preventDefault(); 283 }); 284 box.style.userSelect = "none"; 285 box.style.webkitUserSelect = "none"; 286 box.style.position = box.style.position || "relative"; 287 box.style.overflow = "hidden"; 288 289 var frames = parseFrames(box.getAttribute("data-frames") || "{}"); 290 if (!Object.keys(frames).length) return; 291 292 // Defer one frame so the box has measurable dimensions (the Photos tab is 293 // default-active + keepMounted, so it's visible on first paint). 294 var run = function () { 295 if (REDUCED_MOTION) { 296 staticFrame(box, frames); 297 } else { 298 startWander(box, frames); 299 } 300 }; 301 if (box.offsetWidth > 0) { 302 requestAnimationFrame(run); 303 } else { 304 // Not yet measured â poll briefly until it has a size, then run once. 305 var tries = 0; 306 var wait = setInterval(function () { 307 tries += 1; 308 if (box.offsetWidth > 0 || tries > 40) { 309 clearInterval(wait); 310 run(); 311 } 312 }, 50); 313 } 314 } 315 316 function scan(root) { 317 var nodes = (root || document).querySelectorAll( 318 ".sprite-showcase[data-frames]" 319 ); 320 for (var i = 0; i < nodes.length; i++) { 321 initBox(nodes[i]); 322 } 323 } 324 325 function startObserver() { 326 scan(document); 327 var observer = new MutationObserver(function (mutations) { 328 for (var i = 0; i < mutations.length; i++) { 329 var added = mutations[i].addedNodes; 330 for (var j = 0; j < added.length; j++) { 331 var node = added[j]; 332 if (node.nodeType !== 1) continue; // element nodes only 333 if ( 334 node.classList && 335 node.classList.contains("sprite-showcase") && 336 node.hasAttribute("data-frames") 337 ) { 338 initBox(node); 339 } else if (node.querySelectorAll) { 340 scan(node); 341 } 342 } 343 } 344 }); 345 observer.observe(document.body, { childList: true, subtree: true }); 346 } 347 348 if (document.readyState === "loading") { 349 document.addEventListener("DOMContentLoaded", startObserver); 350 } else { 351 startObserver(); 352 } 353})();
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.