Skip to content

Commit 12ae992

Browse files
committed
feat: implement asset preloading and URL resolution
1 parent f650d64 commit 12ae992

4 files changed

Lines changed: 170 additions & 14 deletions

File tree

src/api/atlas.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { ApiConnector, Region, Language } from '@atlasacademy/api-connector'
1+
import { ApiConnector, Region, Language, Script } from '@atlasacademy/api-connector'
22

33
export { Region }
44
export type RegionType = Region
55

66
export const AssetHost = 'https://static.atlasacademy.io'
77

88
const connectors: Partial<Record<Region, ApiConnector>> = {}
9+
const scriptCache = new Map<string, Promise<Script.SvtScript | null>>()
910

1011
const getConnector = (region: Region) => {
1112
if (!connectors[region]) {
@@ -32,8 +33,18 @@ export const getWar = async (warId: number, region: Region = Region.JP) => {
3233
return connector.war(warId)
3334
}
3435

35-
export const getSvtScript = async (charaId: number, region: Region = Region.JP) => {
36-
const connector = getConnector(region)
37-
const scripts = await connector.svtScript([charaId])
38-
return scripts.length > 0 ? scripts[0] : null
36+
export const getSvtScript = (charaId: number, region: Region = Region.JP): Promise<Script.SvtScript | null> => {
37+
const key = `${region}_${charaId}`
38+
if (scriptCache.has(key)) {
39+
return scriptCache.get(key)!
40+
}
41+
42+
const promise = (async () => {
43+
const connector = getConnector(region)
44+
const scripts = await connector.svtScript([charaId])
45+
return scripts.length > 0 ? scripts[0]! : null
46+
})()
47+
48+
scriptCache.set(key, promise)
49+
return promise
3950
}

src/components/Character.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ref, computed, onMounted, watch } from 'vue'
33
import { getSvtScript, Region } from '@/api/atlas'
44
import { getAssetUrl } from '@/utils/asset'
5+
import { resourceManager } from '@/utils/resourceManager'
56
import type { Script } from '@atlasacademy/api-connector'
67
78
const props = defineProps<{
@@ -36,7 +37,8 @@ const fetchScript = async () => {
3637
console.log(`Fetching data for charaId: ${currentId}`)
3738
3839
// Capture the URL at the start to ensure consistency
39-
const url = assetUrl.value
40+
const rawUrl = assetUrl.value
41+
const url = resourceManager.getResolvedUrl(rawUrl)
4042
4143
// Load Image to get dimensions
4244
const imagePromise = new Promise<{ width: number; height: number; image: HTMLImageElement }>(

src/composables/useScriptPlayer.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ref, onUnmounted } from 'vue'
22
import axios from 'axios'
33
import { getAssetUrl, getBackgroundUrl, getBgmUrl, getSeUrl } from '@/utils/asset'
4+
import { resourceManager } from '@/utils/resourceManager'
5+
import { getSvtScript, Region } from '@/api/atlas'
46

57
export interface ScriptState {
68
background: string | null
@@ -87,11 +89,12 @@ export function useScriptPlayer() {
8789
}
8890

8991
const playBgm = (id: string, volume: number = 1.0, fadeDuration: number = 0) => {
90-
const url = getBgmUrl(id, currentRegion.value)
92+
const rawUrl = getBgmUrl(id, currentRegion.value)
93+
const url = resourceManager.getResolvedUrl(rawUrl)
9194
// Boost volume because script values (e.g. 0.1) are often too quiet for web playback
9295
const adjustedVolume = Math.min(volume * 5.0, 1.0)
9396

94-
if (bgmAudio && !bgmAudio.paused && bgmAudio.src === url) {
97+
if (bgmAudio && !bgmAudio.paused && (bgmAudio.src === url || bgmAudio.src === rawUrl)) {
9598
// Same BGM, just update volume
9699
fadeAudio(bgmAudio, fadeDuration, adjustedVolume)
97100
return
@@ -129,7 +132,8 @@ export function useScriptPlayer() {
129132
}
130133

131134
const playSe = (id: string) => {
132-
const url = getSeUrl(id, currentRegion.value)
135+
const rawUrl = getSeUrl(id, currentRegion.value)
136+
const url = resourceManager.getResolvedUrl(rawUrl)
133137
const audio = new Audio(url)
134138
audio.volume = 1.0 // Default volume for SE
135139

@@ -330,6 +334,87 @@ export function useScriptPlayer() {
330334
}
331335
}
332336

337+
const preloadUpcomingAssets = (startIndex: number, region: string, limitBlocks: number = 3) => {
338+
let blocksFound = 0
339+
let index = startIndex
340+
let isPreloading = true
341+
let hasEncounteredChoice = false
342+
343+
while (index < scriptLines.value.length) {
344+
const line = scriptLines.value[index]
345+
if (!line) {
346+
index++
347+
continue
348+
}
349+
const cmd = parseLine(line)
350+
351+
if (cmd.type === 'choice') {
352+
hasEncounteredChoice = true
353+
// Reset for new branch so we scan into it
354+
blocksFound = 0
355+
isPreloading = true
356+
} else if (cmd.type === 'choiceEnd') {
357+
// End of choice block, stop scanning
358+
break
359+
}
360+
361+
if (isPreloading) {
362+
if (cmd.type === 'command') {
363+
switch (cmd.commandName) {
364+
case 'bgm':
365+
if (cmd.args && cmd.args.length > 0) {
366+
const url = getBgmUrl(cmd.args[0]!, region)
367+
resourceManager.preloadAudio(url)
368+
}
369+
break
370+
case 'se':
371+
if (cmd.args && cmd.args.length > 0) {
372+
const url = getSeUrl(cmd.args[0]!, region)
373+
resourceManager.preloadAudio(url)
374+
}
375+
break
376+
case 'scene':
377+
if (cmd.args && cmd.args.length > 0) {
378+
const url = getBackgroundUrl(cmd.args[0]!, region)
379+
resourceManager.preloadImage(url)
380+
}
381+
break
382+
case 'charaSet':
383+
// [charaSet CODE ID ASCENSION NAME...]
384+
if (cmd.args && cmd.args.length >= 3) {
385+
const id = cmd.args[1]!
386+
const url = getAssetUrl(`CharaFigure/${id}/${id}_merged.png`, region)
387+
resourceManager.preloadImage(url)
388+
389+
// Preload svtScript data
390+
getSvtScript(parseInt(id), region as Region)
391+
}
392+
break
393+
case 'waitClick':
394+
blocksFound++
395+
break
396+
case 'end':
397+
blocksFound = limitBlocks // Stop scanning
398+
break
399+
}
400+
} else if (cmd.type === 'choice') {
401+
// Handled above
402+
}
403+
404+
if (blocksFound >= limitBlocks) {
405+
isPreloading = false
406+
// If we haven't seen a choice, we can stop now.
407+
// Because we are just in linear text and reached the limit.
408+
if (!hasEncounteredChoice) {
409+
break
410+
}
411+
}
412+
}
413+
414+
index++
415+
}
416+
}
417+
333418
const fetchScriptContent = async (url: string) => {
334419
try {
335420
const response = await axios.get(url)
@@ -393,8 +478,12 @@ export function useScriptPlayer() {
393478

394479
const content = await fetchScriptContent(scriptUrl)
395480
scriptLines.value = content.split('\n').filter((l: string) => l.trim() !== '')
481+
396482
currentLineIndex.value = 0
397483

484+
// Preload initial batch (next 3 blocks)
485+
preloadUpcomingAssets(0, region, 3)
486+
398487
// Start processing until first stop
399488
await processNextBlock()
400489
isLoading.value = false
@@ -533,7 +622,8 @@ export function useScriptPlayer() {
533622
// TODO: Handle transitions and visual effects for scene changes
534623
if (cmd.args && cmd.args.length > 0) {
535624
const bgId = cmd.args[0] as string
536-
state.value.background = getBackgroundUrl(bgId, currentRegion.value)
625+
const rawUrl = getBackgroundUrl(bgId, currentRegion.value)
626+
state.value.background = resourceManager.getResolvedUrl(rawUrl)
537627
}
538628
break
539629
case 'bgm':
@@ -669,7 +759,7 @@ export function useScriptPlayer() {
669759
}
670760
}
671761

672-
const next = () => {
762+
const next = async () => {
673763
if (state.value.isFinished) return
674764

675765
// Clear text for next block?
@@ -682,16 +772,22 @@ export function useScriptPlayer() {
682772
// We need to pass region again. Ideally store it in closure or ref.
683773
// For now, let's just default to JP or we need to refactor to store region in state.
684774
// Refactor: store region
685-
processNextBlock()
775+
await processNextBlock()
776+
777+
// Preload next batch
778+
preloadUpcomingAssets(currentLineIndex.value, currentRegion.value, 3)
686779
}
687780

688-
const selectChoice = (choiceId: number) => {
781+
const selectChoice = async (choiceId: number) => {
689782
const startIndex = choiceMap.value[choiceId]
690783
if (startIndex !== undefined) {
691784
currentLineIndex.value = startIndex
692785
state.value.choices = []
693786
state.value.text = '' // Clear text before showing response
694-
processNextBlock()
787+
await processNextBlock()
788+
789+
// Preload next batch
790+
preloadUpcomingAssets(currentLineIndex.value, currentRegion.value, 3)
695791
}
696792
}
697793

src/utils/resourceManager.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
2+
import axios from 'axios'
3+
4+
class ResourceManager {
5+
// Map original URL -> Blob URL
6+
private blobUrls: Map<string, string> = new Map()
7+
private pendingRequests: Map<string, Promise<void>> = new Map()
8+
9+
async preload(url: string): Promise<void> {
10+
if (this.blobUrls.has(url)) return
11+
if (this.pendingRequests.has(url)) return this.pendingRequests.get(url)
12+
13+
const promise = axios.get(url, { responseType: 'blob' })
14+
.then(response => {
15+
const blob = response.data
16+
const objectUrl = URL.createObjectURL(blob)
17+
this.blobUrls.set(url, objectUrl)
18+
this.pendingRequests.delete(url)
19+
})
20+
.catch(e => {
21+
console.warn(`Failed to preload asset: ${url}`, e)
22+
this.pendingRequests.delete(url)
23+
})
24+
25+
this.pendingRequests.set(url, promise)
26+
return promise
27+
}
28+
29+
// Alias for compatibility, but now they do the same thing
30+
async preloadImage(url: string): Promise<void> {
31+
return this.preload(url)
32+
}
33+
34+
async preloadAudio(url: string): Promise<void> {
35+
return this.preload(url)
36+
}
37+
38+
getResolvedUrl(url: string): string {
39+
return this.blobUrls.get(url) || url
40+
}
41+
42+
isLoaded(url: string): boolean {
43+
return this.blobUrls.has(url)
44+
}
45+
}
46+
47+
export const resourceManager = new ResourceManager()

0 commit comments

Comments
 (0)