aboutsummaryrefslogtreecommitdiff
path: root/webAO/viewport/utils/preloadMessageAssets.ts
blob: 0a24e84a25eea05583b6d52c9c7d91a31b4be032 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import { ChatMsg } from "../interfaces/ChatMsg";
import { PreloadedAssets } from "../interfaces/PreloadedAssets";
import {
  resolveAndPreloadImage,
  resolveAndPreloadAudio,
  getAnimDuration,
} from "../../utils/assetCache";
import transparentPng from "../../constants/transparentPng";

const GLOBAL_TIMEOUT_MS = 8000;

/**
 * Builds the list of candidate URLs for a character emote across all extensions.
 * Replicates the URL construction logic from setEmote.ts.
 */
function buildEmoteUrls(
  AO_HOST: string,
  extensions: string[],
  charactername: string,
  emotename: string,
  prefix: string,
): string[] {
  const characterFolder = `${AO_HOST}characters/`;
  const urls: string[] = [];

  for (const extension of extensions) {
    let url: string;
    if (extension === ".png") {
      url = `${characterFolder}${encodeURI(charactername)}/${encodeURI(emotename)}${extension}`;
    } else if (extension === ".webp.static") {
      url = `${characterFolder}${encodeURI(charactername)}/${encodeURI(emotename)}.webp`;
    } else {
      url = `${characterFolder}${encodeURI(charactername)}/${encodeURI(prefix)}${encodeURI(emotename)}${extension}`;
    }
    urls.push(url);
  }

  return urls;
}

const DEFAULT_ASSETS: PreloadedAssets = {
  idleUrl: transparentPng,
  talkingUrl: transparentPng,
  preanimUrl: transparentPng,
  preanimDuration: 0,
  pairIdleUrl: transparentPng,
  shoutSfxUrl: null,
  emoteSfxUrl: null,
  realizationSfxUrl: null,
  stabSfxUrl: null,
};

/**
 * Preloads all assets referenced in an IC message before the animation timeline starts.
 * Resolves all file extensions in parallel and primes the browser cache.
 * All resolution and preloading is cached via assetCache, so repeated messages
 * from the same character with the same emotes resolve near-instantly.
 */
export default async function preloadMessageAssets(
  chatmsg: ChatMsg,
  AO_HOST: string,
  emoteExtensions: string[],
): Promise<PreloadedAssets> {
  const charName = chatmsg.name!.toLowerCase();
  const charEmote = chatmsg.sprite!.toLowerCase();

  const doPreload = async (): Promise<PreloadedAssets> => {
    // Build candidate URL lists for each emote
    const idleUrls = buildEmoteUrls(AO_HOST, emoteExtensions, charName, charEmote, "(a)");
    const talkingUrls = buildEmoteUrls(AO_HOST, emoteExtensions, charName, charEmote, "(b)");

    const hasPreanim =
      chatmsg.type === 1 &&
      chatmsg.preanim &&
      chatmsg.preanim !== "-" &&
      chatmsg.preanim !== "";

    const preanimUrls = hasPreanim
      ? buildEmoteUrls(AO_HOST, emoteExtensions, charName, chatmsg.preanim!.toLowerCase(), "")
      : null;

    const hasPair = !!chatmsg.other_name;
    const pairIdleUrls = hasPair
      ? buildEmoteUrls(AO_HOST, emoteExtensions, chatmsg.other_name!.toLowerCase(), chatmsg.other_emote!.toLowerCase(), "(a)")
      : null;

    // Shout SFX per-character path
    const shoutNames = [undefined, "holdit", "objection", "takethat", "custom"];
    const shoutName = shoutNames[chatmsg.objection];
    const shoutSfxPath = (chatmsg.objection > 0 && chatmsg.objection < 4 && shoutName)
      ? `${AO_HOST}characters/${encodeURI(charName)}/${shoutName}.opus`
      : null;

    // Emote SFX
    const invalidSounds = ["0", "1", "", undefined];
    const emoteSfxPath = (
      !invalidSounds.includes(chatmsg.sound) &&
      (chatmsg.type == 1 || chatmsg.type == 2 || chatmsg.type == 6)
    ) ? `${AO_HOST}sounds/general/${encodeURI(chatmsg.sound.toLowerCase())}.opus`
      : null;

    // Realization and stab SFX (always preloaded - used by \f and \s text commands)
    const realizationPath = `${AO_HOST}sounds/general/sfx-realization.opus`;
    const stabPath = `${AO_HOST}sounds/general/sfx-stab.opus`;

    // Launch everything in parallel - assetCache handles per-URL dedup and caching
    const [
      idleUrl, talkingUrl, preanimUrl, preanimDuration, pairIdleUrl,
      shoutSfxUrl, emoteSfxUrl, realizationSfxUrl, stabSfxUrl,
    ] = await Promise.all([
      resolveAndPreloadImage(idleUrls),
      resolveAndPreloadImage(talkingUrls),
      preanimUrls ? resolveAndPreloadImage(preanimUrls) : Promise.resolve(transparentPng),
      hasPreanim
        ? getAnimDuration(`${AO_HOST}characters/${encodeURI(charName)}/${encodeURI(chatmsg.preanim!.toLowerCase())}`)
        : Promise.resolve(0),
      pairIdleUrls ? resolveAndPreloadImage(pairIdleUrls) : Promise.resolve(transparentPng),
      shoutSfxPath ? resolveAndPreloadAudio(shoutSfxPath) : Promise.resolve(null),
      emoteSfxPath ? resolveAndPreloadAudio(emoteSfxPath) : Promise.resolve(null),
      resolveAndPreloadAudio(realizationPath),
      resolveAndPreloadAudio(stabPath),
    ]);

    return {
      idleUrl,
      talkingUrl,
      preanimUrl,
      preanimDuration,
      pairIdleUrl,
      shoutSfxUrl,
      emoteSfxUrl,
      realizationSfxUrl,
      stabSfxUrl,
    };
  };

  // Race against global timeout for graceful degradation
  const timeoutPromise = new Promise<PreloadedAssets>((resolve) => {
    setTimeout(() => {
      console.warn("Asset preloading timed out, using defaults");
      resolve({ ...DEFAULT_ASSETS });
    }, GLOBAL_TIMEOUT_MS);
  });

  try {
    return await Promise.race([doPreload(), timeoutPromise]);
  } catch (error) {
    console.error("Asset preloading failed:", error);
    return { ...DEFAULT_ASSETS };
  }
}