aboutsummaryrefslogtreecommitdiff
path: root/webAO/utils
diff options
context:
space:
mode:
authorDavid Skoland <davidskoland@gmail.com>2026-04-01 13:59:13 +0200
committerDavid Skoland <davidskoland@gmail.com>2026-04-01 13:59:13 +0200
commit10b413c0f0a31bc9476eed86812b6bb90f82caed (patch)
tree94ac6676fcad76dc76e901e2889a30f7ba611d8d /webAO/utils
parentd6163543f483c35737da52b7e307cf6f65828f82 (diff)
Add asset preloading system for IC message rendering
Fix rendering race conditions where character sprites, pre-animations, and paired character assets were displayed before being downloaded. All assets referenced in an MS packet are now resolved and preloaded into the browser cache before the animation timeline starts. - Add unified assetCache module with session-wide promise caching - Add preloadMessageAssets orchestrator for parallel asset resolution - Cache fileExists HEAD requests so missing files aren't re-probed - Preload all SFX (emote, shout, realization, stab) alongside sprites - Use synchronous setEmoteFromUrl at all render transition points - Graceful fallback to legacy setEmote if preloading times out Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'webAO/utils')
-rw-r--r--webAO/utils/assetCache.ts129
-rw-r--r--webAO/utils/fileExists.ts18
2 files changed, 140 insertions, 7 deletions
diff --git a/webAO/utils/assetCache.ts b/webAO/utils/assetCache.ts
new file mode 100644
index 0000000..7504207
--- /dev/null
+++ b/webAO/utils/assetCache.ts
@@ -0,0 +1,129 @@
+/**
+ * Unified asset resolution and preloading cache.
+ *
+ * Every resolve/preload function caches its promise by URL, so:
+ * - Concurrent calls for the same URL share one in-flight request
+ * - Subsequent calls return instantly from cache
+ * - Results are cached for the entire session (CDN assets don't change mid-session)
+ *
+ * fileExists.ts handles HEAD-request caching independently (same pattern).
+ * This module handles everything above that: URL resolution, image/audio
+ * preloading, and animation duration calculation.
+ */
+
+import findImgSrc from "./findImgSrc";
+import fileExists from "./fileExists";
+import getAnimLength from "./getAnimLength";
+import transparentPng from "../constants/transparentPng";
+
+const PRELOAD_TIMEOUT_MS = 5000;
+const AUDIO_PRELOAD_TIMEOUT_MS = 3000;
+
+/** All cached promises, keyed by a type-prefixed key */
+const cache = new Map<string, Promise<any>>();
+
+// ── Core cache helper ───────────────────────────────────────────────
+
+function cached<T>(key: string, factory: () => Promise<T>): Promise<T> {
+ const existing = cache.get(key);
+ if (existing !== undefined) return existing;
+ const promise = factory();
+ cache.set(key, promise);
+ return promise;
+}
+
+// ── Public API ──────────────────────────────────────────────────────
+
+/**
+ * Resolves which URL from a candidate list exists, then preloads the
+ * image into the browser cache. Returns the resolved URL or transparentPng.
+ *
+ * Cached by the candidate list (stringified), so the same set of
+ * candidates always returns the same resolved+preloaded URL.
+ */
+export function resolveAndPreloadImage(urls: string[]): Promise<string> {
+ const key = `img:${urls.join("|")}`;
+ return cached(key, async () => {
+ const url = await findImgSrc(urls);
+ if (url && url !== transparentPng) {
+ await doPreloadImage(url);
+ }
+ return url;
+ });
+}
+
+/**
+ * Checks if an audio URL exists, then preloads it into the browser cache.
+ * Returns the URL if it exists, or null.
+ */
+export function resolveAndPreloadAudio(url: string): Promise<string | null> {
+ const key = `audio:${url}`;
+ return cached(key, async () => {
+ const exists = await fileExists(url);
+ if (!exists) return null;
+ await doPreloadAudio(url);
+ return url;
+ });
+}
+
+/**
+ * Preloads an audio URL that is already known to exist (no HEAD check).
+ * Useful when the URL comes from a trusted source like client.resources.
+ */
+export function preloadKnownAudio(url: string): Promise<string> {
+ const key = `audio:${url}`;
+ return cached(key, async () => {
+ await doPreloadAudio(url);
+ return url;
+ });
+}
+
+/**
+ * Gets the animation duration for a base URL (tries .gif, .webp, .apng).
+ * Downloads the file to count frames, which also primes the browser cache.
+ */
+export function getAnimDuration(baseUrl: string): Promise<number> {
+ const key = `animdur:${baseUrl}`;
+ return cached(key, () => getAnimLength(baseUrl));
+}
+
+// ── Internal preloaders ─────────────────────────────────────────────
+
+function doPreloadImage(url: string): Promise<void> {
+ return new Promise<void>((resolve) => {
+ const img = new Image();
+ let settled = false;
+
+ const timer = setTimeout(() => {
+ if (!settled) { settled = true; resolve(); }
+ }, PRELOAD_TIMEOUT_MS);
+
+ img.onload = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ img.onerror = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ img.src = url;
+ });
+}
+
+function doPreloadAudio(url: string): Promise<void> {
+ return new Promise<void>((resolve) => {
+ const audio = new Audio();
+ let settled = false;
+
+ const timer = setTimeout(() => {
+ if (!settled) { settled = true; resolve(); }
+ }, AUDIO_PRELOAD_TIMEOUT_MS);
+
+ audio.oncanplaythrough = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ audio.onerror = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ audio.preload = "auto";
+ audio.src = url;
+ });
+}
diff --git a/webAO/utils/fileExists.ts b/webAO/utils/fileExists.ts
index 1dceb72..748bc1f 100644
--- a/webAO/utils/fileExists.ts
+++ b/webAO/utils/fileExists.ts
@@ -1,14 +1,15 @@
-export default async function fileExists(url: string): Promise<boolean> {
- return new Promise((resolve) => {
+const cache = new Map<string, Promise<boolean>>();
+
+export default function fileExists(url: string): Promise<boolean> {
+ const cached = cache.get(url);
+ if (cached !== undefined) return cached;
+
+ const promise = new Promise<boolean>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("HEAD", url);
xhr.onload = function checkLoad() {
if (xhr.readyState === 4) {
- if (xhr.status === 200) {
- resolve(true);
- } else {
- resolve(false);
- }
+ resolve(xhr.status === 200);
}
};
xhr.onerror = function checkError() {
@@ -16,4 +17,7 @@ export default async function fileExists(url: string): Promise<boolean> {
};
xhr.send(null);
});
+
+ cache.set(url, promise);
+ return promise;
}