diff --git a/public/animate-test.html b/public/animate-test.html
index 0e32ad8..9752391 100644
--- a/public/animate-test.html
+++ b/public/animate-test.html
@@ -96,19 +96,19 @@
(canvas.offsetHeight * window.devicePixelRatio) /
library.properties.height;
- movieClip.alpha = 0;
- const tween = createjs.Tween.get(movieClip, { paused: true }).to(
- { alpha: 1 },
- 200
- );
- stage.on(
- "drawend",
- () => {
- tween.paused = false;
- },
- null,
- true
- );
+ // movieClip.alpha = 0;
+ // const tween = createjs.Tween.get(movieClip, { paused: true }).to(
+ // { alpha: 1 },
+ // 200
+ // );
+ // stage.on(
+ // "drawend",
+ // () => {
+ // tween.paused = false;
+ // },
+ // null,
+ // true
+ // );
// TODO: I'm not 100% clear on why, but manually caching the movie and
// manually updating the cache at a 60FPS rate (that's how often
@@ -119,13 +119,13 @@
// layers, so it looks better? Although hell, maybe applying
// alpha to a cached raster just _is_ faster than applying it to
// like 200 overlapping layers, that would just make sense...
- movieClip.cache(
- 0,
- 0,
- library.properties.width,
- library.properties.height
- );
- movieClip.on("tick", () => movieClip.updateCache());
+ // movieClip.cache(
+ // 0,
+ // 0,
+ // library.properties.width,
+ // library.properties.height
+ // );
+ // movieClip.on("tick", () => movieClip.updateCache());
stage.addChild(movieClip);
diff --git a/src/app/components/OutfitCanvas.js b/src/app/components/OutfitCanvas.js
index 6b4230e..20360d1 100644
--- a/src/app/components/OutfitCanvas.js
+++ b/src/app/components/OutfitCanvas.js
@@ -1,5 +1,7 @@
import React from "react";
+import { safeImageUrl } from "../util";
+
const EaselContext = React.createContext({
stage: null,
addResizeListener: () => {},
@@ -26,9 +28,9 @@ function OutfitCanvas({ children, width, height }) {
}
window.createjs.Ticker.timingMode = window.createjs.Ticker.RAF;
- window.createjs.Ticker.on("tick", onTick);
+ window.createjs.Ticker.addEventListener("tick", onTick);
- return () => window.createjs.Ticker.off("tick", onTick);
+ return () => window.createjs.Ticker.removeEventListener("tick", onTick);
}, [loading]);
const addChild = React.useCallback(
@@ -182,6 +184,116 @@ export function OutfitCanvasImage({ src, zIndex }) {
return null;
}
+export function OutfitCanvasMovie({ librarySrc, zIndex }) {
+ const {
+ width,
+ height,
+ addChild,
+ removeChild,
+ addResizeListener,
+ removeResizeListener,
+ } = React.useContext(EaselContext);
+
+ React.useEffect(() => {
+ let library;
+ let movieClip;
+ let tween;
+
+ function updateSize() {
+ movieClip.scaleX = width / library.properties.width;
+ movieClip.scaleY = height / library.properties.height;
+ }
+
+ async function addMovieClip() {
+ library = await loadMovieLibrary(librarySrc);
+ let constructorName;
+ try {
+ const fileName = librarySrc.split("/").pop();
+ const fileNameWithoutExtension = fileName.split(".")[0];
+ constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
+ } catch (e) {
+ console.error(
+ `Movie librarySrc %s did not match expected format: %o`,
+ JSON.stringify(librarySrc),
+ e
+ );
+ return;
+ }
+
+ const LibraryMovieClipConstructor = library[constructorName];
+ if (!LibraryMovieClipConstructor) {
+ console.error(
+ `Expected JS movie library %s to contain a constructor named ` +
+ `%s, but it did not: %o`,
+ JSON.stringify(librarySrc),
+ JSON.stringify(constructorName),
+ library
+ );
+ return;
+ }
+ movieClip = new LibraryMovieClipConstructor();
+ movieClip.cache(
+ 0,
+ 0,
+ library.properties.width,
+ library.properties.height
+ );
+ movieClip.on("tick", () => {
+ console.log("clip tick", movieClip.framerate);
+ movieClip.updateCache();
+ });
+
+ // We're gonna fade in! Wait for the first frame to draw, to make the
+ // timing smooth, but yeah here we go!
+ movieClip.alpha = 0;
+ tween = window.createjs.Tween.get(movieClip, { paused: true }).to(
+ { alpha: 1 },
+ 200
+ );
+ const startFadeIn = () => {
+ console.log("first draw");
+ tween.paused = false;
+ };
+
+ // Get it actually running! We need to set framerate _after_ adding it
+ // to the stage, to overwrite the stage's defaults.
+ updateSize();
+ addChild(movieClip, zIndex, { afterFirstDraw: startFadeIn });
+ movieClip.framerate = library.properties.fps;
+
+ addResizeListener(updateSize);
+ }
+
+ function removeMovieClip() {
+ removeResizeListener(updateSize);
+ removeChild(movieClip);
+ }
+
+ addMovieClip();
+
+ return () => {
+ if (movieClip) {
+ // Reverse the fade-in into a fade-out, then remove the bitmap.
+ tween.reversed = true;
+ tween.setPosition(0);
+ tween.paused = false;
+ tween.on("complete", removeMovieClip, null, true);
+ }
+ };
+ }, [
+ librarySrc,
+ zIndex,
+ width,
+ height,
+ addChild,
+ removeChild,
+ addResizeListener,
+ removeResizeListener,
+ ]);
+
+ return null;
+}
+
/**
* useEaselDependenciesLoader loads the CreateJS scripts we use in OutfitCanvas.
* We load it as part of OutfitCanvas, but callers can also use this to preload
@@ -215,14 +327,11 @@ function useScriptTag(src) {
}
let canceled = false;
- const script = document.createElement("script");
- script.onload = () => {
+ loadScriptTag(src).then(() => {
if (!canceled) {
setLoading(false);
}
- };
- script.src = src;
- document.body.appendChild(script);
+ });
return () => {
canceled = true;
@@ -246,4 +355,61 @@ export function loadImage(url) {
return promise;
}
+async function loadMovieLibrary(librarySrc) {
+ // These library JS files are interesting in their operation. It seems like
+ // the idea is, it pushes an object to a global array, and you need to snap
+ // it up and see it at the end of the array! And I don't really see a way to
+ // like, get by a name or ID that we know by this point. So, here we go, just
+ // try to grab it once it arrives!
+ //
+ // TODO: How reliable is the timing on this? My assumption is that, the
+ // scripts will trigger their onloads in order of arrival, and my
+ // _hope_ is that the onload will execute before the next script to
+ // arrive executes. Let's, ah, find out!
+ await loadScriptTag(librarySrc);
+ const composition = Object.values(window.AdobeAn.compositions).pop();
+ const library = composition.getLibrary();
+
+ // One more loading step as part of loading this library is loading the
+ // images it uses for sprites.
+ //
+ // TODO: I guess the manifest has these too, so if we could use our DB cache
+ // to get the manifest to us faster, then we could avoid a network RTT
+ // on the critical path by preloading these images before the JS file
+ // even gets to us?
+ const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
+ const manifestImages = new Map(
+ library.properties.manifest.map(({ id, src }) => [
+ id,
+ loadImage(safeImageUrl(librarySrcDir + "/" + src)),
+ ])
+ );
+ await Promise.all(manifestImages.values());
+
+ // Finally, once we have the images loaded, the library object expects us to
+ // mutate it (!) to give it the actual sprite sheet objects based on the
+ // loaded images. That's how the MovieClip's objects will access the loaded
+ // versions!
+ const spriteSheets = composition.getSpriteSheet();
+ for (const { name, frames } of library.ssMetadata) {
+ const image = await manifestImages.get(name);
+ spriteSheets[name] = new window.createjs.SpriteSheet({
+ images: [image],
+ frames,
+ });
+ }
+
+ return library;
+}
+
+function loadScriptTag(src) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.onload = () => resolve(script);
+ script.onerror = (e) => reject(e);
+ script.src = src;
+ document.body.appendChild(script);
+ });
+}
+
export default OutfitCanvas;
diff --git a/src/app/util.js b/src/app/util.js
index c27c8e0..ff4357a 100644
--- a/src/app/util.js
+++ b/src/app/util.js
@@ -65,7 +65,15 @@ export function Heading2({ children, ...props }) {
* safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
*/
export function safeImageUrl(url) {
- return `/api/assetProxy?url=${encodeURIComponent(url)}`;
+ let safeUrl = `/api/assetProxy?url=${encodeURIComponent(url)}`;
+
+ // On our Storybook server, we need to request from the main dev server.
+ const { host } = document.location;
+ if (host === "localhost:6006") {
+ safeUrl = "http://localhost:3000" + safeUrl;
+ }
+
+ return safeUrl;
}
/**
diff --git a/src/stories/OutfitCanvas.stories.js b/src/stories/OutfitCanvas.stories.js
index 67e63e5..60aae55 100644
--- a/src/stories/OutfitCanvas.stories.js
+++ b/src/stories/OutfitCanvas.stories.js
@@ -2,38 +2,87 @@ import React from "react";
import OutfitCanvas, {
OutfitCanvasImage,
+ OutfitCanvasMovie,
} from "../app/components/OutfitCanvas";
export default {
title: "Dress to Impress/OutfitCanvas",
component: OutfitCanvas,
+ argTypes: {
+ pet: {
+ name: "Pet",
+ control: {
+ type: "radio",
+ options: ["None", "Blue Acara"],
+ },
+ },
+ items: {
+ name: "Items",
+ control: {
+ type: "multi-select",
+ options: ["Bubbles In Water Foreground"],
+ },
+ },
+ },
};
-export const BlueAcara = () => (
+// NOTE: We don't bother with assetProxy here, because we only run Storybook
+// locally, and localhost isn't subject to the same mixed content rules.
+// So this is noticeably faster!
+
+const Template = (args) => (
-
-
-
-
-
-
+ {args.pet === "Blue Acara" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {args.items.includes("Bubbles In Water Foreground") && (
+
+ )}
);
+
+export const BlueAcara = Template.bind({});
+BlueAcara.args = {
+ pet: "Blue Acara",
+ items: [],
+};
+
+export const BubblesOnWaterForeground = Template.bind({});
+BubblesOnWaterForeground.args = {
+ pet: "None",
+ items: ["Bubbles In Water Foreground"],
+};
+
+export const BlueAcaraWithForeground = Template.bind({});
+BlueAcaraWithForeground.args = {
+ pet: "Blue Acara",
+ items: ["Bubbles In Water Foreground"],
+};