diff --git a/src/assets/AssetGraph.ts b/src/assets/AssetGraph.ts new file mode 100644 index 0000000..85b93ae --- /dev/null +++ b/src/assets/AssetGraph.ts @@ -0,0 +1,102 @@ +import type { AssetInspectorSnapshot } from "./AssetInspectorSnapshot"; + +interface SessionEntry { + owner: string; + bundles: Set; +} + +export class AssetGraph { + private sessions: Map = new Map(); + private ownerToSessions: Map> = new Map(); + + registerSession(sessionId: string, owner: string): void { + this.sessions.set(sessionId, { + owner, + bundles: new Set(), + }); + + const sessions = this.ownerToSessions.get(owner) ?? new Set(); + sessions.add(sessionId); + this.ownerToSessions.set(owner, sessions); + } + + trackBundle(sessionId: string, bundleName: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + session.bundles.add(bundleName); + } + + untrackBundle(sessionId: string, bundleName: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + session.bundles.delete(bundleName); + } + + getSessionBundles(sessionId: string): string[] { + const session = this.sessions.get(sessionId); + if (!session) { + return []; + } + return [...session.bundles]; + } + + collectSnapshot( + bundleRefCounts: Map, + sessionRefCounts: Map> + ): AssetInspectorSnapshot { + const bundles: AssetInspectorSnapshot["bundles"] = {}; + for (const [bundleName, refCount] of bundleRefCounts.entries()) { + if (refCount <= 0) { + continue; + } + const owners = new Set(); + const sessions = new Set(); + for (const [sessionId, refs] of sessionRefCounts.entries()) { + const count = refs.get(bundleName) ?? 0; + if (count <= 0) { + continue; + } + sessions.add(sessionId); + owners.add(this.sessions.get(sessionId)?.owner ?? "unknown"); + } + bundles[bundleName] = { + refCount, + owners: [...owners].sort(), + sessions: [...sessions].sort(), + }; + } + + const sessionSnapshot: AssetInspectorSnapshot["sessions"] = {}; + for (const [sessionId, session] of this.sessions.entries()) { + sessionSnapshot[sessionId] = { + owner: session.owner, + bundles: [...session.bundles].sort(), + }; + } + + const ownerSnapshot: AssetInspectorSnapshot["owners"] = {}; + for (const [owner, sessions] of this.ownerToSessions.entries()) { + const bundlesByOwner = new Set(); + for (const sessionId of sessions) { + for (const bundle of this.sessions.get(sessionId)?.bundles ?? []) { + bundlesByOwner.add(bundle); + } + } + ownerSnapshot[owner] = { + sessions: [...sessions].sort(), + bundles: [...bundlesByOwner].sort(), + }; + } + + return { + activeBundles: Object.keys(bundles).sort(), + bundles, + sessions: sessionSnapshot, + owners: ownerSnapshot, + }; + } +} diff --git a/src/assets/AssetInspectorSnapshot.ts b/src/assets/AssetInspectorSnapshot.ts new file mode 100644 index 0000000..37847a6 --- /dev/null +++ b/src/assets/AssetInspectorSnapshot.ts @@ -0,0 +1,22 @@ +export interface AssetBundleSnapshot { + refCount: number; + owners: string[]; + sessions: string[]; +} + +export interface AssetSessionSnapshot { + owner: string; + bundles: string[]; +} + +export interface AssetOwnerSnapshot { + sessions: string[]; + bundles: string[]; +} + +export interface AssetInspectorSnapshot { + activeBundles: string[]; + bundles: Record; + sessions: Record; + owners: Record; +} diff --git a/src/assets/AssetSession.ts b/src/assets/AssetSession.ts new file mode 100644 index 0000000..c5e8a51 --- /dev/null +++ b/src/assets/AssetSession.ts @@ -0,0 +1,32 @@ +import type { AssetsBundle } from "pixi.js"; +import type { AssetManager } from "@/core/AssetManager"; + +export class AssetSession { + readonly id: string; + readonly owner: string; + private manager: AssetManager; + + constructor(manager: AssetManager, id: string, owner: string) { + this.manager = manager; + this.id = id; + this.owner = owner; + } + + loadBundle( + name: string, + onProgress?: (progress: number) => void + ): Promise { + return this.manager.acquireBySession(this.id, name, onProgress); + } + + releaseBundle(name: string): Promise { + return this.manager.releaseBySession(this.id, name); + } + + async releaseAll(): Promise { + const bundles = this.manager.getSessionBundles(this.id); + for (const bundle of bundles) { + await this.releaseBundle(bundle); + } + } +} diff --git a/src/core/AssetManager.ts b/src/core/AssetManager.ts index 1252b17..b738cd9 100644 --- a/src/core/AssetManager.ts +++ b/src/core/AssetManager.ts @@ -1,16 +1,75 @@ -import { Assets, type AssetsBundle } from "pixi.js"; +import type { AssetsBundle } from "pixi.js"; +import { AssetGraph } from "@/assets/AssetGraph"; +import { AssetSession } from "@/assets/AssetSession"; +import type { AssetInspectorSnapshot } from "@/assets/AssetInspectorSnapshot"; import { logger } from "./Logger"; -class AssetManager { - private referenceCounts: Map = new Map(); +export interface AssetRuntimeAdapter { + init: () => Promise; + loadBundle: ( + name: string, + onProgress?: (progress: number) => void + ) => Promise; + unloadBundle: (name: string) => Promise; +} - async init(): Promise { - // 硬编码的 /manifest.json 路径是本应用的固定配置 +const createPixiAdapter = (): AssetRuntimeAdapter => ({ + init: async () => { + const { Assets } = await import("pixi.js"); await Assets.init({ manifest: "/manifest.json" }); + }, + loadBundle: async (name, onProgress) => { + const { Assets } = await import("pixi.js"); + return Assets.loadBundle(name, onProgress); + }, + unloadBundle: async (name) => { + const { Assets } = await import("pixi.js"); + await Assets.unloadBundle(name); + }, +}); + +export class AssetManager { + private adapter: AssetRuntimeAdapter; + private bundleRefCounts: Map = new Map(); + private bundleCache: Map = new Map(); + private sessionBundleRefs: Map> = new Map(); + private graph: AssetGraph = new AssetGraph(); + private sessionIdSeed = 0; + private defaultSession: AssetSession; + + constructor(adapter?: AssetRuntimeAdapter) { + this.adapter = adapter ?? createPixiAdapter(); + this.defaultSession = this.createSession("global"); + } + + async init(): Promise { + await this.adapter.init(); logger.debug("AssetManager initialized"); } - async loadBundle( + createSession(owner: string): AssetSession { + const normalizedOwner = owner.trim(); + if (normalizedOwner === "") { + throw new Error("AssetManager: Owner name cannot be empty string"); + } + + this.sessionIdSeed += 1; + const sessionId = `session-${this.sessionIdSeed}`; + this.graph.registerSession(sessionId, normalizedOwner); + this.sessionBundleRefs.set(sessionId, new Map()); + return new AssetSession(this, sessionId, normalizedOwner); + } + + getInspectorSnapshot(): AssetInspectorSnapshot { + return this.graph.collectSnapshot(this.bundleRefCounts, this.sessionBundleRefs); + } + + getSessionBundles(sessionId: string): string[] { + return this.graph.getSessionBundles(sessionId); + } + + async acquireBySession( + sessionId: string, name: string, onProgress?: (progress: number) => void ): Promise { @@ -19,20 +78,32 @@ class AssetManager { return null; } - // 已经加载过,增加引用计数 - if (this.referenceCounts.has(name)) { - const count = this.referenceCounts.get(name)! + 1; - this.referenceCounts.set(name, count); - logger.debug(`AssetManager: reuse bundle "${name}", refCount = ${count}`); + const sessionRefs = this.sessionBundleRefs.get(sessionId); + if (!sessionRefs) { + logger.warn(`AssetManager: unknown session "${sessionId}"`); + return null; + } + + const totalRefCount = this.bundleRefCounts.get(name) ?? 0; + const sessionRefCount = sessionRefs.get(name) ?? 0; + if (totalRefCount > 0) { + this.bundleRefCounts.set(name, totalRefCount + 1); + sessionRefs.set(name, sessionRefCount + 1); + this.graph.trackBundle(sessionId, name); + logger.debug( + `AssetManager: reuse bundle "${name}", refCount = ${totalRefCount + 1}` + ); onProgress?.(1); - return Assets.loadBundle(name); + return this.bundleCache.get(name) ?? this.adapter.loadBundle(name); } - // 首次加载 logger.debug(`AssetManager: loading bundle "${name}"`); try { - const bundle = await Assets.loadBundle(name, onProgress); - this.referenceCounts.set(name, 1); + const bundle = await this.adapter.loadBundle(name, onProgress); + this.bundleRefCounts.set(name, 1); + this.bundleCache.set(name, bundle); + sessionRefs.set(name, 1); + this.graph.trackBundle(sessionId, name); return bundle; } catch (error) { logger.error(`AssetManager: Failed to load bundle "${name}":`, error); @@ -40,36 +111,62 @@ class AssetManager { } } - async unloadBundle(name: string): Promise { + async releaseBySession(sessionId: string, name: string): Promise { if (name === "") { logger.warn("AssetManager: Bundle name cannot be empty string"); return; } - if (!this.referenceCounts.has(name)) { + const sessionRefs = this.sessionBundleRefs.get(sessionId); + if (!sessionRefs) { + logger.warn(`AssetManager: unknown session "${sessionId}"`); + return; + } + + const sessionRefCount = sessionRefs.get(name) ?? 0; + if (sessionRefCount <= 0) { logger.warn(`AssetManager: unloading unloaded bundle "${name}"`); return; } - const count = this.referenceCounts.get(name)! - 1; - this.referenceCounts.set(name, count); + if (sessionRefCount === 1) { + sessionRefs.delete(name); + this.graph.untrackBundle(sessionId, name); + } else { + sessionRefs.set(name, sessionRefCount - 1); + } + + const totalRefCount = (this.bundleRefCounts.get(name) ?? 0) - 1; + this.bundleRefCounts.set(name, totalRefCount); - if (count <= 0) { + if (totalRefCount <= 0) { logger.debug(`AssetManager: unloading bundle "${name}"`); - await Assets.unloadBundle(name); - this.referenceCounts.delete(name); + await this.adapter.unloadBundle(name); + this.bundleRefCounts.delete(name); + this.bundleCache.delete(name); } else { - logger.debug(`AssetManager: decrease refCount for "${name}" to ${count}`); + logger.debug(`AssetManager: decrease refCount for "${name}" to ${totalRefCount}`); } } + async loadBundle( + name: string, + onProgress?: (progress: number) => void + ): Promise { + return this.defaultSession.loadBundle(name, onProgress); + } + + async unloadBundle(name: string): Promise { + return this.defaultSession.releaseBundle(name); + } + isLoaded(name: string): boolean { if (name === "") { logger.warn("AssetManager: Bundle name cannot be empty string"); return false; } - return this.referenceCounts.has(name) && this.referenceCounts.get(name)! > 0; + return this.bundleRefCounts.has(name) && this.bundleRefCounts.get(name)! > 0; } getRefCount(name: string): number { @@ -78,13 +175,16 @@ class AssetManager { return 0; } - return this.referenceCounts.get(name) ?? 0; + return this.bundleRefCounts.get(name) ?? 0; } clearAll(): void { - // 注意:此方法只会清除本地引用计数,不会从 Pixi 中卸载资源 - // 因为完全卸载资源在实际应用中很少需要,且可能导致正在使用的资源失效 - this.referenceCounts.clear(); + this.bundleRefCounts.clear(); + this.bundleCache.clear(); + this.sessionBundleRefs.clear(); + this.sessionIdSeed = 0; + this.graph = new AssetGraph(); + this.defaultSession = this.createSession("global"); logger.debug("AssetManager: Reference count map cleared"); } } diff --git a/tests/assets/asset-session.test.ts b/tests/assets/asset-session.test.ts new file mode 100644 index 0000000..83010a8 --- /dev/null +++ b/tests/assets/asset-session.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { AssetManager } from "@/core/AssetManager"; + +type FakeBundle = Record; + +describe("AssetSession", () => { + it("tracks owner/session relationships in inspector snapshot", async () => { + const loadCalls: string[] = []; + const unloadCalls: string[] = []; + const manager = new AssetManager({ + init: async () => undefined, + loadBundle: async (name: string) => { + loadCalls.push(name); + return { name } as FakeBundle; + }, + unloadBundle: async (name: string) => { + unloadCalls.push(name); + }, + }); + + const sceneSessionA = manager.createSession("scene"); + const sceneSessionB = manager.createSession("scene"); + const uiSession = manager.createSession("ui"); + + await sceneSessionA.loadBundle("characters"); + await sceneSessionB.loadBundle("background"); + await uiSession.loadBundle("characters"); + + const snapshot = manager.getInspectorSnapshot(); + + expect(snapshot.sessions[sceneSessionA.id]?.owner).toBe("scene"); + expect(snapshot.sessions[sceneSessionB.id]?.owner).toBe("scene"); + expect(snapshot.sessions[uiSession.id]?.owner).toBe("ui"); + expect(snapshot.owners.scene?.sessions).toContain(sceneSessionA.id); + expect(snapshot.owners.scene?.sessions).toContain(sceneSessionB.id); + expect(snapshot.owners.ui?.sessions).toContain(uiSession.id); + expect(snapshot.activeBundles.sort()).toEqual(["background", "characters"]); + expect(loadCalls).toEqual(["characters", "background"]); + expect(unloadCalls).toEqual([]); + }); + + it("clears active bundles after releaseAll", async () => { + const unloadCalls: string[] = []; + const manager = new AssetManager({ + init: async () => undefined, + loadBundle: async (name: string) => ({ name }) as FakeBundle, + unloadBundle: async (name: string) => { + unloadCalls.push(name); + }, + }); + + const session = manager.createSession("battle"); + await session.loadBundle("fx"); + await session.loadBundle("music"); + + expect(manager.getInspectorSnapshot().activeBundles.sort()).toEqual([ + "fx", + "music", + ]); + + await session.releaseAll(); + + const snapshot = manager.getInspectorSnapshot(); + expect(snapshot.activeBundles).toEqual([]); + expect(snapshot.bundles).toEqual({}); + expect(unloadCalls.sort()).toEqual(["fx", "music"]); + }); +});