2026-06-18 18:59:56 +09:00
|
|
|
"use strict";
|
|
|
|
|
const images = new Map();
|
|
|
|
|
const imageBoundsCache = new WeakMap();
|
|
|
|
|
const imageMetrics = new Map();
|
2026-06-19 23:22:15 +09:00
|
|
|
const imagePromises = new Map();
|
|
|
|
|
const imageAssetIndex = new Map();
|
2026-06-19 02:27:19 +09:00
|
|
|
|
|
|
|
|
const scaledSpriteCache = new Map();
|
|
|
|
|
const stainOverlayCache = new Map();
|
|
|
|
|
|
2026-06-20 23:27:39 +09:00
|
|
|
const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02", "genkotsu"]);
|
|
|
|
|
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02", "genkotsu"]);
|
2026-06-19 23:22:15 +09:00
|
|
|
|
2026-06-19 02:27:19 +09:00
|
|
|
function trimCanvasCache(cache, limit = 420) {
|
|
|
|
|
while (cache.size > limit) {
|
|
|
|
|
const first = cache.keys().next().value;
|
|
|
|
|
if (first === undefined) break;
|
|
|
|
|
cache.delete(first);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function quantizedCanvasSize(v, step = 16) {
|
|
|
|
|
return Math.max(1, Math.round(v / step) * step);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SPRITE_CACHE_SCALE = 2.0;
|
|
|
|
|
|
|
|
|
|
function getCachedSpriteCanvas(id, img, w, h) {
|
|
|
|
|
if (!img || !w || !h) return img;
|
|
|
|
|
const qw = quantizedCanvasSize(w, 16);
|
|
|
|
|
const qh = quantizedCanvasSize(h, 16);
|
|
|
|
|
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, 520);
|
|
|
|
|
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, 16);
|
|
|
|
|
const qh = quantizedCanvasSize(h, 16);
|
|
|
|
|
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}`;
|
|
|
|
|
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;
|
|
|
|
|
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";
|
|
|
|
|
stainOverlayCache.set(key, canvas);
|
|
|
|
|
trimCanvasCache(stainOverlayCache, 360);
|
|
|
|
|
return canvas;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 23:22:15 +09:00
|
|
|
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),
|
|
|
|
|
visibleBottomRatio: bounds ? (bounds.y + bounds.h) / Math.max(bounds.imageH, 1) : 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;
|
|
|
|
|
const bounds = ALPHA_BOUNDS[asset.id] || { x: 0, y: 0, w, h, imageW: w, imageH: h };
|
|
|
|
|
imageMetrics.set(asset.id, {
|
|
|
|
|
w,
|
|
|
|
|
h,
|
|
|
|
|
ratio: h / Math.max(w, 1),
|
|
|
|
|
visibleBottomRatio: (bounds.y + bounds.h) / Math.max(bounds.imageH, 1),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)) 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);
|
|
|
|
|
const promise = new Promise((resolve) => {
|
|
|
|
|
img.onload = () => { markImageLoaded(asset, img); resolve(img); };
|
|
|
|
|
img.onerror = () => { resolve(null); };
|
2026-06-18 18:59:56 +09:00
|
|
|
});
|
2026-06-19 23:22:15 +09:00
|
|
|
imagePromises.set(asset.id, promise);
|
2026-06-21 14:09:35 +09:00
|
|
|
if (!img.src) img.src = (window.TarinaiVersion?.withStaticVersion ? window.TarinaiVersion.withStaticVersion(asset.path) : asset.path);
|
2026-06-19 23:22:15 +09:00
|
|
|
return promise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureImage(id) {
|
|
|
|
|
setupAssetIndex();
|
|
|
|
|
const asset = imageAssetIndex.get(id);
|
|
|
|
|
if (!asset) return null;
|
|
|
|
|
primeImageMetrics(asset);
|
|
|
|
|
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)) return images.get(id);
|
|
|
|
|
if (fallbackId && fallbackId !== id) ensureImage(fallbackId);
|
|
|
|
|
if (isImageReady(fallbackId)) return images.get(fallbackId);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 23:27:39 +09:00
|
|
|
function loadImages(ids = null, onProgress = null) {
|
2026-06-19 23:22:15 +09:00
|
|
|
setupAssetIndex();
|
|
|
|
|
const requested = ids ? new Set(ids) : INITIAL_IMAGE_IDS;
|
2026-06-20 23:27:39 +09:00
|
|
|
const assets = [...requested].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;
|
|
|
|
|
}));
|
2026-06-19 23:22:15 +09:00
|
|
|
return Promise.all(tasks).then(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startBackgroundImageLoading() {
|
|
|
|
|
setupAssetIndex();
|
|
|
|
|
const allAssets = [...SPRITES, ...DECOR_ASSETS];
|
|
|
|
|
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(() => loadGroup(EARLY_IMAGE_IDS, "auto"), 450);
|
|
|
|
|
schedule(() => {
|
|
|
|
|
for (const asset of allAssets) loadImageAsset(asset, "low");
|
|
|
|
|
}, 2200);
|
2026-06-18 18:59:56 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function imageAlphaBounds(img) {
|
|
|
|
|
if (!img) return null;
|
|
|
|
|
if (img.alphaBounds) return img.alphaBounds;
|
|
|
|
|
if (imageBoundsCache.has(img)) return imageBoundsCache.get(img);
|
|
|
|
|
|
|
|
|
|
const w = img.naturalWidth || img.width || 0;
|
|
|
|
|
const h = img.naturalHeight || img.height || 0;
|
|
|
|
|
if (!w || !h) return null;
|
|
|
|
|
const bounds = { x: 0, y: 0, w, h, imageW: w, imageH: h };
|
|
|
|
|
imageBoundsCache.set(img, bounds);
|
|
|
|
|
return bounds;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getImageMetrics(idOrImg) {
|
2026-06-19 23:22:15 +09:00
|
|
|
if (typeof idOrImg === "string") {
|
|
|
|
|
setupAssetIndex();
|
|
|
|
|
const asset = imageAssetIndex.get(idOrImg);
|
|
|
|
|
if (asset) primeImageMetrics(asset);
|
|
|
|
|
return imageMetrics.get(idOrImg) || null;
|
|
|
|
|
}
|
2026-06-18 18:59:56 +09:00
|
|
|
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, visibleBottomRatio: 1 } : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 23:22:15 +09:00
|
|
|
setupAssetIndex();
|
2026-06-18 18:59:56 +09:00
|
|
|
|
2026-06-19 23:22:15 +09:00
|
|
|
window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, imagePromises, scaledSpriteCache, stainOverlayCache, loadImages, startBackgroundImageLoading, ensureImage, isImageReady, getRenderableImage, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas };
|