"use strict"; const images = new Map(); const imageMetrics = new Map(); const imagePromises = new Map(); const imageAssetIndex = new Map(); const imageLastUsedAt = new Map(); const DECOR_ASSETS = [ { id: "zunchi", path: "assets/objects/zunchi.webp" }, { id: "zunchi_02", path: "assets/objects/zunchi_02.webp" }, { id: "ant_queen", path: "assets/objects/ant_queen.webp" }, { id: "ant_worker", path: "assets/objects/ant_worker.webp" }, { id: "genkotsu", path: "assets/objects/genkotsu.webp" }, { id: "pushpin", path: "assets/objects/pushpin.webp" }, { id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" }, { id: "oshibyo", path: "assets/objects/oshibyo.webp" }, { id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" }, { id: "plushie_bear", path: "assets/objects/plushie_bear.png" }, ]; const ALPHA_BOUNDS = { smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 }, teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 }, jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 }, drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 }, cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 }, sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 }, pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 }, stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 }, hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 }, weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 }, back: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 }, flee: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 }, zunda_eat: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 }, hurt2: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 }, normal_smirk: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 }, normal_tongue: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 }, fear: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 }, flee_fear2: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 }, sleep2: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, normal_happy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, fear_blue: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, fear_cry: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 }, intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 }, zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 }, hungry_70: { x: 0, y: 0, w: 430, h: 366, imageW: 430, imageH: 366 }, zunchi_slave_alt: { x: 42, y: 22, w: 425, h: 426, imageW: 512, imageH: 468 }, zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 }, zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 }, ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 }, ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 }, genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 }, sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 }, sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, hot_1: { x: 16, y: 20, w: 480, h: 428, imageW: 512, imageH: 468 }, hot_2: { x: 16, y: 20, w: 480, h: 428, imageW: 512, imageH: 468 }, hot_3: { x: 16, y: 20, w: 480, h: 428, imageW: 512, imageH: 468 }, cold_1: { x: 16, y: 17, w: 480, h: 433, imageW: 512, imageH: 468 }, cold_2: { x: 16, y: 17, w: 480, h: 433, imageW: 512, imageH: 468 }, cold_3: { x: 16, y: 17, w: 480, h: 433, imageW: 512, imageH: 468 }, plushie_bear: { x: 0, y: 0, w: 150, h: 192, imageW: 150, imageH: 192 }, }; const scaledSpriteCache = new Map(); const stainOverlayCache = new Map(); const SPRITE_CACHE_LIMIT = 128; const STAIN_CACHE_LIMIT = 64; const IMAGE_LOAD_TIMEOUT_MS = 9000; const IMAGE_KEEP_IDS = new Set(["smile"]); let imageLoadTimeoutsTotal = 0; const SUNBATH_IMAGE_IDS = new Set(SPRITES .map(asset => asset?.id || "") .filter(id => String(id).startsWith("sunbath"))); const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); function trimCanvasCache(cache, limit = 128) { while (cache.size > limit) { const first = cache.keys().next().value; if (first === undefined) break; cache.delete(first); } } function quantizedCanvasSize(v, step = 24) { return Math.max(1, Math.round(v / step) * step); } const SPRITE_CACHE_SCALE = 1.55; function getCachedSpriteCanvas(id, img, w, h) { if (!img || !w || !h) return img; const qw = quantizedCanvasSize(w); const qh = quantizedCanvasSize(h); const key = `${id}:${qw}x${qh}`; let canvas = scaledSpriteCache.get(key); if (canvas) return canvas; canvas = document.createElement("canvas"); canvas.width = qw; canvas.height = qh; const c = canvas.getContext("2d"); c.imageSmoothingEnabled = true; if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; c.clearRect(0, 0, qw, qh); c.drawImage(img, 0, 0, qw, qh); scaledSpriteCache.set(key, canvas); trimCanvasCache(scaledSpriteCache, SPRITE_CACHE_LIMIT); return canvas; } function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) { if (!img || stain <= 0.01 || !w || !h) return null; const qw = quantizedCanvasSize(w); const qh = quantizedCanvasSize(h); const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8))); const seedBucket = Math.round((seed || 0) * 16); const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`; const buildOverlay = (c) => { if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; c.clearRect(0, 0, qw, qh); c.drawImage(img, 0, 0, qw, qh); c.globalCompositeOperation = "source-in"; c.globalAlpha = 0.34 + bucket * 0.075; c.fillStyle = "rgba(28, 92, 22, 0.96)"; const s = (seedBucket % 16) / 16; const spots = [ [0.40 + s * 0.035, 0.82, 0.14, 0.040, -0.10], [0.59 - s * 0.030, 0.87, 0.15, 0.044, 0.12], [0.50, 0.91, 0.20, 0.036, 0.02], [0.48 + s * 0.020, 0.78, 0.10, 0.030, -0.18], ]; for (const [sx, sy, rw, rh, rot] of spots) { c.beginPath(); c.ellipse(sx * qw, sy * qh, rw * qw, rh * qh, rot, 0, Math.PI * 2); c.fill(); } c.globalAlpha = 1; c.globalCompositeOperation = "source-over"; }; let canvas = stainOverlayCache.get(key); if (canvas) return canvas; canvas = document.createElement("canvas"); canvas.width = qw; canvas.height = qh; const c = canvas.getContext("2d"); c.imageSmoothingEnabled = true; buildOverlay(c); stainOverlayCache.set(key, canvas); trimCanvasCache(stainOverlayCache, STAIN_CACHE_LIMIT); return canvas; } function primeImageMetrics(asset) { if (!asset || imageMetrics.has(asset.id)) return; const bounds = ALPHA_BOUNDS[asset.id]; const w = bounds?.imageW || 512; const h = bounds?.imageH || 512; imageMetrics.set(asset.id, { w, h, ratio: h / Math.max(w, 1), }); } function setupAssetIndex() { if (imageAssetIndex.size) return; for (const asset of [...SPRITES, ...DECOR_ASSETS]) { imageAssetIndex.set(asset.id, asset); primeImageMetrics(asset); } } function markImageLoaded(asset, img) { const w = img.naturalWidth || img.width || ALPHA_BOUNDS[asset.id]?.imageW || 512; const h = img.naturalHeight || img.height || ALPHA_BOUNDS[asset.id]?.imageH || 512; imageMetrics.set(asset.id, { w, h, ratio: h / Math.max(w, 1), }); } function touchImage(id) { if (id) imageLastUsedAt.set(id, Date.now()); } function clearCanvasCacheForImage(id) { const prefix = `${id}:`; for (const key of [...scaledSpriteCache.keys()]) if (String(key).startsWith(prefix)) scaledSpriteCache.delete(key); for (const key of [...stainOverlayCache.keys()]) if (String(key).startsWith(prefix)) stainOverlayCache.delete(key); } function unloadImageAsset(id) { if (!id || IMAGE_KEEP_IDS.has(id)) return false; const img = images.get(id); if (!img) return false; clearCanvasCacheForImage(id); images.delete(id); imagePromises.delete(id); imageLastUsedAt.delete(id); try { img.onload = null; img.onerror = null; img.removeAttribute?.("src"); img.src = ""; } catch (_) {} return true; } function isImageReady(id) { const img = images.get(id); return !!(img && img.complete && (img.naturalWidth || img.width)); } function loadImageAsset(asset, priority = "auto") { if (!asset) return Promise.resolve(null); setupAssetIndex(); primeImageMetrics(asset); if (isImageReady(asset.id)) { touchImage(asset.id); return Promise.resolve(images.get(asset.id)); } if (imagePromises.has(asset.id)) return imagePromises.get(asset.id); const img = images.get(asset.id) || new Image(); img.decoding = "async"; if ("fetchPriority" in img) img.fetchPriority = priority; img.alphaBounds = ALPHA_BOUNDS[asset.id] || null; images.set(asset.id, img); let promise; let settled = false; promise = new Promise((resolve) => { const finish = (value, timedOut = false) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (timedOut) imageLoadTimeoutsTotal += 1; resolve(value); }; const timer = window.setTimeout ? window.setTimeout(() => finish(null, true), IMAGE_LOAD_TIMEOUT_MS) : null; img.onload = () => { markImageLoaded(asset, img); touchImage(asset.id); finish(img); }; img.onerror = () => { finish(null); }; }).finally(() => { if (imagePromises.get(asset.id) === promise) imagePromises.delete(asset.id); }); imagePromises.set(asset.id, promise); if (!img.src) img.src = window.TARINAI_APP?.withVersion ? window.TARINAI_APP.withVersion(asset.path) : asset.path; return promise; } function ensureImage(id) { setupAssetIndex(); const asset = imageAssetIndex.get(id); if (!asset) return null; primeImageMetrics(asset); touchImage(id); const existing = images.get(id); if (!existing || !existing.src) loadImageAsset(asset, "low"); return images.get(id) || null; } function getRenderableImage(id, fallbackId = "smile") { if (id) ensureImage(id); if (isImageReady(id)) { touchImage(id); return images.get(id); } if (fallbackId && fallbackId !== id) ensureImage(fallbackId); if (isImageReady(fallbackId)) { touchImage(fallbackId); return images.get(fallbackId); } return null; } function loadImages(onProgress = null) { setupAssetIndex(); const assets = [...INITIAL_IMAGE_IDS].map(id => imageAssetIndex.get(id)).filter(Boolean); const total = Math.max(assets.length, 1); let done = 0; if (typeof onProgress === "function") onProgress(done, total, null); const tasks = assets.map(asset => loadImageAsset(asset, "high").then((img) => { done += 1; if (typeof onProgress === "function") onProgress(done, total, asset); return img; })); return Promise.all(tasks).then(() => undefined); } function trimImageMemory(mode = "normal") { const aggressive = mode === true || mode === "aggressive" || mode === "hidden"; const hidden = mode === "hidden"; trimCanvasCache(scaledSpriteCache, aggressive ? 24 : SPRITE_CACHE_LIMIT); trimCanvasCache(stainOverlayCache, aggressive ? 8 : STAIN_CACHE_LIMIT); const now = Date.now(); const maxLoaded = hidden ? 1 : (aggressive ? 20 : 48); const maxIdleMs = hidden ? 0 : (aggressive ? 20000 : 120000); const candidates = []; for (const [id, img] of images) { if (!img || IMAGE_KEEP_IDS.has(id)) continue; const lastUsed = imageLastUsedAt.get(id) || 0; candidates.push({ id, lastUsed }); } candidates.sort((a, b) => a.lastUsed - b.lastUsed); let loadedCount = images.size; let released = 0; for (const entry of candidates) { if (!hidden && loadedCount <= maxLoaded && now - entry.lastUsed <= maxIdleMs) continue; if (unloadImageAsset(entry.id)) { loadedCount -= 1; released += 1; } } return { released, loaded: images.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size }; } if (typeof document !== "undefined") { document.addEventListener("visibilitychange", () => { if (document.hidden) trimImageMemory("hidden"); }); } if (typeof window !== "undefined") { window.setInterval(() => { const profile = window.TarinaiPerf?.simulationOptimizationTier?.(); trimImageMemory(profile === "aggressive" ? "aggressive" : "normal"); }, 30000); } function startBackgroundImageLoading() { setupAssetIndex(); const loadGroup = (ids, priority = "low") => { for (const id of ids) { const asset = imageAssetIndex.get(id); if (asset) loadImageAsset(asset, priority); } }; const schedule = window.requestIdleCallback ? (fn, timeout = 1200) => window.requestIdleCallback(fn, { timeout }) : (fn) => setTimeout(fn, 60); schedule(() => { const profile = window.TarinaiPerf?.simulationOptimizationTier?.() || "balanced"; const ids = profile === "aggressive" ? [...EARLY_IMAGE_IDS].slice(0, 18) : EARLY_IMAGE_IDS; loadGroup(ids, "auto"); }, 450); schedule(() => { const profile = window.TarinaiPerf?.simulationOptimizationTier?.() || "balanced"; if (profile === "full") { for (const asset of [...SPRITES, ...DECOR_ASSETS]) loadImageAsset(asset, "low"); } trimImageMemory(profile === "aggressive" ? "aggressive" : "normal"); }, 2200); } function imageAlphaBounds(img) { if (!img) return null; if (img.alphaBounds) return img.alphaBounds; const w = img.naturalWidth || img.width || 0; const h = img.naturalHeight || img.height || 0; return w && h ? { x: 0, y: 0, w, h, imageW: w, imageH: h } : null; } function getImageMetrics(idOrImg) { if (typeof idOrImg === "string") { setupAssetIndex(); const asset = imageAssetIndex.get(idOrImg); if (asset) primeImageMetrics(asset); return imageMetrics.get(idOrImg) || null; } if (!idOrImg) return null; for (const [id, img] of images) { if (img === idOrImg) return imageMetrics.get(id) || null; } const w = idOrImg.naturalWidth || idOrImg.width || 0; const h = idOrImg.naturalHeight || idOrImg.height || 0; return w && h ? { w, h, ratio: h / w } : null; } setupAssetIndex(); if (typeof window !== "undefined") { window.TarinaiAssetsMemory = Object.freeze({ trim: trimImageMemory, unload: unloadImageAsset, stats() { return { loaded: images.size, pending: imagePromises.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size, timeouts: imageLoadTimeoutsTotal }; }, }); }