tarinai/js/render.js
2026-06-28 13:40:41 +09:00

1803 lines
77 KiB
JavaScript

"use strict";
const TARINAI_RENDER_GLOBAL = typeof globalThis !== "undefined" ? globalThis : (typeof window !== "undefined" ? window : {});
const toolCursorIconCache = new Map();
const toolCursorIconImages = new Map();
function toolCursorIconDefinition(world) {
const tool = String(world?.tool || "");
if (!tool || tool === "observe" || tool === "undo" || tool === "redo" || tool === "shoot") return null;
const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null;
if (!def) return null;
return { tool, def, type: def.itemType || (def.placeable ? tool : "") };
}
function staticVersionedAssetPath(path) {
const raw = String(path || "");
if (!raw) return "";
const app = TARINAI_RENDER_GLOBAL.TARINAI_APP;
if (app?.withVersion) return app.withVersion(raw);
return raw;
}
function cachedToolCursorIconImage(path) {
const src = staticVersionedAssetPath(path);
if (!src) return null;
let img = toolCursorIconImages.get(src);
if (!img && typeof Image !== "undefined") {
img = new Image();
img.decoding = "async";
img.src = src;
toolCursorIconImages.set(src, img);
}
return img || null;
}
function drawToolCursorIconFallback(ctx, icon, size) {
const value = String(icon || "");
if (!value) return false;
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${Math.round(size * 0.58)}px ui-rounded, sans-serif`;
ctx.fillStyle = "rgba(44, 36, 28, 0.78)";
ctx.fillText(value, size / 2, size / 2 + size * 0.02);
ctx.restore();
return true;
}
function buildToolCursorIconCanvas(tool, def, type, size) {
const key = `${tool}:${type || "-"}:${size}`;
const cached = toolCursorIconCache.get(key);
if (cached) return cached;
const ownerDocument = typeof document !== "undefined" ? document : null;
const canvas = ownerDocument?.createElement?.("canvas") || null;
if (!canvas) return null;
canvas.width = size;
canvas.height = size;
const c = canvas.getContext("2d");
if (!c) return null;
c.clearRect(0, 0, size, size);
let drawn = false;
if (type && typeof TARINAI_RENDER_GLOBAL.drawToolItemPreview === "function") {
try { drawn = TARINAI_RENDER_GLOBAL.drawToolItemPreview(c, type, { width: size, height: size, compact: true, watermark: false }) !== false; }
catch (_) { drawn = false; }
}
if (!drawn && def?.icon) {
const img = cachedToolCursorIconImage(def.icon);
if (img && img.complete && (img.naturalWidth || img.width)) {
const pad = Math.max(4, size * 0.12);
c.drawImage(img, pad, pad, size - pad * 2, size - pad * 2);
drawn = true;
} else {
// Do not cache a label fallback while the real icon is still loading.
// Otherwise tools such as "new" and "delete" can remain as kanji text
// until a hard reload even after the image becomes available.
return null;
}
}
if (!drawn) drawn = drawToolCursorIconFallback(c, def?.iconText || def?.label || tool, size);
if (!drawn) return null;
toolCursorIconCache.set(key, canvas);
while (toolCursorIconCache.size > 64) toolCursorIconCache.delete(toolCursorIconCache.keys().next().value);
return canvas;
}
function drawSelectedToolCursorIcon(ctx, world) {
const p = world?.pointer;
if (!p?.inside) return;
const info = toolCursorIconDefinition(world);
if (!info) return;
const activeUi = typeof uiCache !== "undefined" ? uiCache : null;
if (activeUi?.panning || activeUi?.grabbing || activeUi?.hosing || activeUi?.areaDeleting) return;
const screen = world.worldToScreen ? world.worldToScreen(p.x, p.y) : { x: p.x, y: p.y };
const canvasW = world.viewportW || world.w || ctx.canvas?.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas?.height || 1;
const size = Math.max(30, Math.min(46, Math.round(Math.min(canvasW, canvasH) * 0.055)));
const offset = Math.max(6, Math.round(size * 0.16));
let x = screen.x + offset;
let y = screen.y + offset;
x = clamp(x, 8, canvasW - size - 8);
y = clamp(y, 8, canvasH - size - 8);
let icon = buildToolCursorIconCanvas(info.tool, info.def, info.type, 96);
if (!icon && info.def?.icon) {
const img = cachedToolCursorIconImage(info.def.icon);
if (img && img.complete && (img.naturalWidth || img.width)) icon = img;
}
if (!icon) {
// Icon image may still be loading; request one more frame once it arrives.
cachedToolCursorIconImage(info.def?.icon || "");
return;
}
ctx.save();
ctx.globalAlpha = 0.52;
ctx.shadowColor = "rgba(255,255,255,0.36)";
ctx.shadowBlur = 5;
ctx.drawImage(icon, x, y, size, size);
ctx.restore();
}
function strokeWorldObstacleRect(ctx, rect) {
if (!rect) return false;
if (rect.oriented) {
ctx.save();
ctx.translate(rect.cx || 0, rect.cy || 0);
ctx.rotate(rect.angle || 0);
ctx.strokeRect(-(rect.halfW || 0), -(rect.halfH || 0), (rect.halfW || 0) * 2, (rect.halfH || 0) * 2);
ctx.restore();
return true;
}
ctx.strokeRect(rect.left || 0, rect.top || 0, Math.max(1, (rect.right || 0) - (rect.left || 0)), Math.max(1, (rect.bottom || 0) - (rect.top || 0)));
return true;
}
function drawToolTargetHoverHighlight(ctx, world) {
if (!world) return;
const tool = String(world.tool || "");
const target = tool === "pinch" ? world.hoverGrabTarget : (tool === "delete" ? world.hoverDeleteTarget : null);
if (!target || target.dead || target.playerHeld || target._heldByPlayer) return;
const p = world.pointer || {};
if (!p.inside) return;
const style = tool === "delete"
? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "\u524a\u9664" }
: { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "\u3064\u307e\u3080" };
const pulse = 0.62 + Math.sin((world.time || 0) * 9.5) * 0.12;
const worldLine = world.screenSizeToWorld ? world.screenSizeToWorld(4.6) : 4.6;
const outerLine = world.screenSizeToWorld ? world.screenSizeToWorld(2.1) : 2.1;
const drawLabel = (cx, cy, ry) => {
const labelY = cy - ry - (world.screenSizeToWorld ? world.screenSizeToWorld(14) : 14);
const labelW = Math.max(42, (world.screenSizeToWorld ? world.screenSizeToWorld(52) : 52));
const labelH = Math.max(18, (world.screenSizeToWorld ? world.screenSizeToWorld(21) : 21));
ctx.save();
ctx.shadowBlur = 0;
ctx.globalAlpha = 0.96;
ctx.fillStyle = "rgba(255,255,255,0.94)";
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(1.2, outerLine);
if (typeof roundedRect === "function") { roundedRect(ctx, cx - labelW / 2, labelY - labelH / 2, labelW, labelH, labelH / 2); ctx.fill(); ctx.stroke(); }
else ctx.strokeRect(cx - labelW / 2, labelY - labelH / 2, labelW, labelH);
ctx.fillStyle = style.color;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(12) : 12)}px sans-serif`;
ctx.fillText(style.label, cx, labelY + 0.5);
ctx.restore();
};
const strokeEllipse = (cx, cy, rx, ry) => {
ctx.fillStyle = style.fill;
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
ctx.shadowColor = style.color;
ctx.shadowBlur = Math.max(12, world.screenSizeToWorld ? world.screenSizeToWorld(18) : 18);
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(3.6, worldLine + pulse * 1.8);
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.stroke();
ctx.shadowBlur = 0;
ctx.setLineDash([10, 6]);
ctx.strokeStyle = "rgba(255,255,255,0.96)";
ctx.lineWidth = Math.max(1.6, outerLine);
ctx.beginPath(); ctx.ellipse(cx, cy, rx * 1.10, ry * 1.12, 0, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
drawLabel(cx, cy, ry * 1.12);
};
ctx.save();
ctx.globalAlpha = 0.92;
const isTarinaiTarget = typeof Tarinai !== "undefined" && target instanceof Tarinai;
if (isTarinaiTarget) {
const r = Math.max(17, target.radius || 22);
strokeEllipse(target.x || 0, target.y || 0, r * 1.02, r * 0.80);
} else if (target.type === "rope" || target.type === "rod") {
const runtime = TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime;
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(target, 1), world);
if (a && b) {
ctx.shadowColor = style.color;
ctx.shadowBlur = Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(15) : 15);
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(5, worldLine + 2);
ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.shadowBlur = 0;
ctx.setLineDash([10, 6]);
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.lineWidth = Math.max(2, outerLine);
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.setLineDash([]);
drawLabel((a.x + b.x) / 2, (a.y + b.y) / 2, 20);
}
} else {
const rects = world.solidObstacleRects?.(target) || [];
if (rects.length) {
ctx.strokeStyle = style.color;
ctx.shadowColor = style.color;
ctx.shadowBlur = Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(16) : 16);
ctx.lineWidth = Math.max(3.2, worldLine);
let cx = target.x || 0, cy = target.y || 0, top = cy;
for (const rect of rects.slice(0, 14)) {
if (rect.oriented) { cx = rect.cx || cx; cy = rect.cy || cy; top = Math.min(top, cy - (rect.halfH || 18)); }
strokeWorldObstacleRect(ctx, rect);
}
ctx.shadowBlur = 0;
ctx.setLineDash([9, 6]);
ctx.strokeStyle = "rgba(255,255,255,0.96)";
ctx.lineWidth = Math.max(1.6, outerLine);
for (const rect of rects.slice(0, 14)) strokeWorldObstacleRect(ctx, rect);
ctx.setLineDash([]);
drawLabel(cx, top, 14);
} else {
const r = Math.max(14, target.r || 16) * (target.type === "ball" ? 1.30 : 1.18);
strokeEllipse(target.x || 0, target.y || 0, r, r * 0.76);
}
}
ctx.restore();
}
function drawShootHoverHighlight(ctx, world) {
if (!world || world.tool !== "shoot") return;
const aim = world.shootAimPoint?.();
if (!aim?.inside) return;
const target = world.hoverShootTarget;
if (!target || target.dead) return;
ctx.save();
const pulse = 0.52 + Math.sin((world.time || 0) * 14) * 0.12;
ctx.globalAlpha = 0.72 + pulse * 0.12;
ctx.lineWidth = Math.max(1.8, (world.screenSizeToWorld ? world.screenSizeToWorld(2.0) : 2.0));
ctx.strokeStyle = "rgba(255,106,92,0.94)";
ctx.shadowColor = "rgba(255,188,146,0.44)";
ctx.shadowBlur = Math.max(4, (world.screenSizeToWorld ? world.screenSizeToWorld(6) : 6));
const isTarinaiTarget = typeof Tarinai !== "undefined" && target instanceof Tarinai;
const isItemTarget = Boolean(target.type);
if (isTarinaiTarget) {
const r = Math.max(16, target.radius || 22) * 1.12;
ctx.beginPath();
ctx.ellipse(target.x || 0, target.y || 0, r * 0.95, r * 0.76, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (isItemTarget) {
const rects = world.solidObstacleRects?.(target) || [];
if (target.type === "rope" || target.type === "rod") {
const runtime = world.TarinaiLinkRuntime || TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime;
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(target, 1), world);
if (a && b) {
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
} else if (rects.length) {
for (const rect of rects.slice(0, 12)) strokeWorldObstacleRect(ctx, rect);
} else {
const r = Math.max(12, target.r || 14) * (target.type === "ball" ? 1.24 : 1.14);
ctx.beginPath();
ctx.ellipse(target.x || 0, target.y || 0, r, r * 0.76, 0, 0, Math.PI * 2);
ctx.stroke();
}
} else {
const r = Math.max(8, target.r || 8) * 1.45;
ctx.beginPath();
ctx.ellipse(target.x || 0, target.y || 0, r, r * 0.86, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
function drawShootCursorFallback(ctx, x, y, size) {
const half = size * 0.5;
const tick = size * 0.16;
const gap = size * 0.11;
ctx.save();
ctx.translate(x, y);
ctx.strokeStyle = "rgba(94,118,138,0.96)";
ctx.lineWidth = Math.max(1.3, size * 0.045);
ctx.lineCap = "square";
for (const sign of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(0, sign * gap);
ctx.lineTo(0, sign * (half - 2));
ctx.stroke();
ctx.beginPath();
ctx.moveTo(sign * gap, 0);
ctx.lineTo(sign * (half - 2), 0);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-tick * 0.55, sign * (gap + tick));
ctx.lineTo(tick * 0.55, sign * (gap + tick));
ctx.stroke();
ctx.beginPath();
ctx.moveTo(sign * (gap + tick), -tick * 0.55);
ctx.lineTo(sign * (gap + tick), tick * 0.55);
ctx.stroke();
}
ctx.restore();
}
function drawShootCursorOverlay(ctx, world) {
if (!world || world.tool !== "shoot") return;
const aim = world.shootAimPoint?.();
if (!aim?.inside) return;
const screen = world.worldToScreen ? world.worldToScreen(aim.x, aim.y) : { x: aim.x, y: aim.y };
const canvasW = world.viewportW || world.w || ctx.canvas?.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas?.height || 1;
const size = Math.max(90, Math.min(160, Math.round(Math.min(canvasW, canvasH) * 0.205)));
const def = typeof toolDefinition === "function" ? toolDefinition("shoot") : null;
const img = cachedToolCursorIconImage(def?.icon || "");
ctx.save();
ctx.globalAlpha = 0.95;
ctx.shadowColor = "rgba(255,255,255,0.12)";
ctx.shadowBlur = Math.max(2, size * 0.08);
if (img && img.complete && (img.naturalWidth || img.width)) {
ctx.drawImage(img, screen.x - size / 2, screen.y - size / 2, size, size);
} else {
drawShootCursorFallback(ctx, screen.x, screen.y, size);
}
ctx.restore();
}
function randSeed(seed, min, max) {
const s = Math.sin(seed * 12.9898) * 43758.5453;
return min + (s - Math.floor(s)) * (max - min);
}
function roundedBlob(ctx, x, y, w, h, wobble = 4) {
ctx.beginPath();
const steps = 28;
for (let i = 0; i <= steps; i++) {
const a = (i / steps) * Math.PI * 2;
const rr = 1 + Math.sin(a * 3.1) * 0.025 + Math.cos(a * 5.7) * 0.018;
const px = x + Math.cos(a) * w * rr + Math.sin(a * 2.0) * wobble * 0.08;
const py = y + Math.sin(a) * h * rr + Math.cos(a * 1.7) * wobble * 0.08;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
}
function getLightingState(targetWorld) {
const progress = targetWorld.dayProgress();
const light = targetWorld.lightLevel();
const dawnStrength = clamp(1 - Math.abs(progress - 0.22) / 0.14, 0, 1);
const duskStrength = clamp(1 - Math.abs(progress - 0.58) / 0.16, 0, 1);
const goldenStrength = Math.max(dawnStrength * 0.62, duskStrength);
const nightStrength = clamp(1 - light * 1.62, 0, 0.54);
const noonStrength = clamp((light - 0.72) / 0.28, 0, 1) * clamp(1 - goldenStrength * 0.72, 0, 1);
const elevation = clamp(Math.sin(progress * Math.PI), 0.10, 1);
const horizon = Math.cos(progress * Math.PI * 2 - Math.PI / 2);
const shadowLength = lerp(1.58, 0.52, elevation);
const warmth = clamp(0.28 + goldenStrength * 0.70 + noonStrength * 0.18 - nightStrength * 0.12, 0, 1);
const bloom = CONFIG.lightingQuality === "cinematic" ? clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58) : 0;
return {
quality: CONFIG.lightingQuality,
progress,
light,
dawnStrength,
duskStrength,
goldenStrength,
nightStrength,
noonStrength,
elevation,
warmth,
bloom,
sunDirX: -horizon,
sunDirY: 0.42 + (1 - elevation) * 0.32,
shadowLength,
shadowColor: nightStrength > 0.22 ? "rgba(40, 50, 91, 0.36)" : (goldenStrength > 0.15 ? "rgba(116, 76, 48, 0.28)" : "rgba(83, 72, 54, 0.27)"),
shadowAlpha: (nightStrength > 0.22 ? 0.135 : lerp(0.13, 0.22, 1 - elevation) + goldenStrength * 0.035),
ambientTint: nightStrength > 0.2 ? "#253a8e" : (goldenStrength > 0.2 ? "#ffad6d" : "#fff7d6"),
vignetteAlpha: nightStrength > 0.18 ? 0.13 + nightStrength * 0.13 : 0.06 + goldenStrength * 0.045,
};
}
function projectedShadowParams(lighting, scale = 1) {
const edgeLight = clamp((1 - lighting.elevation) * 0.60 + lighting.goldenStrength * 0.40 + lighting.nightStrength * 0.55, 0, 1);
const moonLit = lighting.nightStrength > 0.18;
const lightEdge = moonLit
? (lighting.progress < 0.5 ? 1 : -1)
: (lighting.progress < 0.5 ? -1 : 1);
const castDir = -lightEdge;
const weather = world.weather || "sunny";
const weatherAlpha = weather === "sunny" ? 1.12 : weather === "cloudy" ? 0.72 : 0.38;
const length = lerp(0.62, 1.82, edgeLight) * scale;
return {
dx: castDir * 12 * length,
dy: 5.2 * length,
skew: castDir * lerp(0.08, 0.34, edgeLight),
flatness: lerp(0.42, 0.24, edgeLight),
spread: lerp(0.98, 1.52, edgeLight),
alpha: clamp((0.16 + edgeLight * 0.09 - lighting.nightStrength * 0.04) * (0.92 + scale * 0.12) * weatherAlpha, 0.05, 0.32),
color: lighting.nightStrength > 0.22 ? "rgba(42, 48, 72, 0.34)" : "rgba(78, 65, 43, 0.31)",
};
}
function imageVisibleBottomFromTop(img, top, drawH) {
const b = imageAlphaBounds(img);
if (!b) return top + drawH;
return top + ((b.y + b.h) / b.imageH) * drawH;
}
function drawImageProjectedShadow(ctx, img, x, groundY, drawW, drawH, params, options = {}) {
if (!img) return false;
const alpha = options.alpha ?? params.alpha;
const rx = Math.max(5, drawW * (options.widthScale ?? 1) * 0.30);
const ry = Math.max(2, drawH * (options.heightScale ?? 1) * 0.055);
ctx.save();
ctx.globalAlpha = clamp(alpha, 0.08, 0.22);
ctx.fillStyle = params.color;
ctx.beginPath();
ctx.ellipse(Math.round(x), Math.round(groundY), rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return true;
}
function drawProjectedShadow(ctx, x, y, rx, ry, params) {
ctx.save();
ctx.globalAlpha = clamp(params.alpha || 0.10, 0.035, 0.16);
ctx.fillStyle = params.color || "rgba(78, 65, 43, 0.24)";
ctx.beginPath();
ctx.ellipse(Math.round(x), Math.round(y), Math.max(3, rx * 0.82), Math.max(1.5, ry * 0.46), 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawRadialGlow(ctx, x, y, radius, color, alpha) {
if (alpha <= 0 || radius <= 0) return;
ctx.save();
ctx.globalCompositeOperation = "lighter";
const g = ctx.createRadialGradient(x, y, 0, x, y, radius);
g.addColorStop(0, color.replace("ALPHA", alpha.toFixed(3)));
g.addColorStop(0.52, color.replace("ALPHA", (alpha * 0.34).toFixed(3)));
g.addColorStop(1, color.replace("ALPHA", "0"));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
// \u500b\u5225\u306e\u767a\u5149\u30b0\u30e9\u30c7\u30fc\u30b7\u30e7\u30f3\u306f\u500b\u4f53\u6570\u5897\u52a0\u6642\u306e\u8ca0\u8377\u304c\u9ad8\u3044\u305f\u3081\u3001\u901a\u5e38\u63cf\u753b\u306b\u7d71\u5408\u3059\u308b\u3002
return;
}
function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = 1) {
if (!influence) return;
ctx.save();
ctx.setLineDash([10, 7]);
ctx.lineWidth = 1.6;
ctx.restore();
}
function placementPreviewFor(world) {
const p = world?.pointer;
const type = toolItemType(world?.tool || "");
if (!p?.inside || !type) return null;
const pad = CONFIG.worldPadding || 30;
let x = p.x;
let y = p.y;
const sizeScale = world?.toolSizeScale ? world.toolSizeScale(type) : 1;
const sizeName = world?.toolSizeFor ? world.toolSizeFor(type) : "medium";
const r = itemRadiusFor(type, 12) * sizeScale;
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad));
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
let blocked = false;
let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false };
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0);
if (type === "reciprocator") {
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
pb?.setScalar?.(tmp, "railAxis", tmp.angle, "preview-axis");
tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
pb?.syncPoseFromItem?.(tmp);
}
if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) {
const rawRect = world.fenceRect(tmp);
return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) };
}
if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
pb?.setScalar?.(tmp, "motorSpeed", Math.PI * 0.65, "preview");
pb?.setScalar?.(tmp, "motorOn", true, "preview");
pb?.setScalar?.(tmp, "thickness", 12, "preview");
pb?.setSegments?.(tmp, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview");
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "poison_block" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
// Use the actual default poison-block physics footprint for placement
// feedback. Previously it fell through to the generic oval preview, which
// did not match the placed shape.
pb?.ensureBody?.(tmp, null, { syncFromLegacy: false });
tmp.world = null;
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
pb?.setScalar?.(tmp, "railOn", true, "preview");
pb?.setScalar?.(tmp, "railMotorSpeed", 92, "preview");
pb?.setScalar?.(tmp, "railTravel", 150, "preview");
pb?.setScalar?.(tmp, "railPhase", 0, "preview");
pb?.setScalar?.(tmp, "railAnchorX", x, "preview");
pb?.setScalar?.(tmp, "railAnchorY", y, "preview");
pb?.setScalar?.(tmp, "thickness", 12, "preview");
pb?.setSegments?.(tmp, [[-78, 0, 78, 0]], "preview");
const rects = world.reciprocatorObstacleRects(tmp) || [];
const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "nest_box" && world.nestBoxBaseRect) {
const rect = world.nestBoxBaseRect(tmp);
return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) };
}
if (type === "grass") {
blocked = !(world.canAddGrass?.(1) ?? true) || !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
}
if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp);
return { type, x, y, r, blocked };
}
function drawPlacementPreview(ctx, world) {
const preview = placementPreviewFor(world);
if (!preview) return;
const { type, x, y, r, rect, rects, influence, blocked } = preview;
const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10;
ctx.save();
ctx.globalAlpha = blocked ? 0.44 : 0.72;
ctx.lineWidth = blocked ? 2.2 : 2.0;
ctx.setLineDash(blocked ? [5, 4] : [7, 5]);
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.86)" : "rgba(110,178,55,0.88)";
ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)";
if (influence) drawDeviceInfluenceShape(ctx, influence, blocked, 1.15);
const drawRectPreview = (rr) => {
if (!rr) return;
if (rr.oriented) {
ctx.save();
ctx.translate(rr.cx || x, rr.cy || y);
ctx.rotate(rr.angle || 0);
roundedRect(ctx, -(rr.halfW || (rr.right - rr.left) / 2), -(rr.halfH || (rr.bottom - rr.top) / 2), (rr.halfW || (rr.right - rr.left) / 2) * 2, (rr.halfH || (rr.bottom - rr.top) / 2) * 2, Math.min(8, Math.max(2, rr.halfH || 4)));
ctx.fill();
ctx.stroke();
ctx.restore();
} else {
roundedRect(ctx, rr.left, rr.top, rr.right - rr.left, rr.bottom - rr.top, 8);
ctx.fill();
ctx.stroke();
}
};
if (rect || (rects && rects.length)) {
if (rects && rects.length) for (const rr of rects) drawRectPreview(rr);
else drawRectPreview(rect);
if (type === "nest_box" && world.nestBoxSolidRects) {
ctx.setLineDash([3, 4]);
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.38)" : "rgba(110,178,55,0.42)";
for (const part of world.nestBoxSolidRects({ type, x, y, r, dead: false })) {
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
}
}
} else if (type === "stone" || type === "ball" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
ctx.beginPath();
ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.beginPath();
ctx.ellipse(x, y, Math.max(9, r * 1.55), Math.max(6, r * 0.86), 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.setLineDash([]);
ctx.globalAlpha = blocked ? 0.52 : 0.70;
ctx.beginPath();
ctx.moveTo(x - 8, y);
ctx.lineTo(x + 8, y);
ctx.moveTo(x, y - 8);
ctx.lineTo(x, y + 8);
ctx.stroke();
ctx.restore();
}
function drawOperationToolPreview(ctx, world) {
const p = world?.pointer;
if (!p?.inside) return;
if (world?.tool === "copy" && world.copyBuffer) {
const type = world.copyBuffer.type || "";
const item = world.copyPreviewItemAt?.(p.x, p.y);
if (item) {
const pad = typeof CONFIG !== "undefined" ? (CONFIG.worldPadding ?? 0) : 0;
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad));
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
const previewRects = world.solidObstacleRects?.(item) || [];
const boundsBlocked = previewRects.length
? previewRects.some(rr => rectOutside(rr.oriented ? {
left: (rr.cx || p.x) - (rr.halfW || 10) - (rr.halfH || 5),
right: (rr.cx || p.x) + (rr.halfW || 10) + (rr.halfH || 5),
top: (rr.cy || p.y) - (rr.halfW || 10) - (rr.halfH || 5),
bottom: (rr.cy || p.y) + (rr.halfW || 10) + (rr.halfH || 5),
} : rr))
: circleOutside(p.x, p.y, Math.max(14, (item.r || itemRadiusFor?.(type, 16) || 16) * 1.25));
const blocked = boundsBlocked || Boolean(world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item));
let copyPreviewDrew = false;
if (typeof item.draw === "function") {
ctx.save();
ctx.globalAlpha = blocked ? 0.44 : 0.74;
try { item.draw(ctx, world.time || 0, typeof getLightingState === "function" ? getLightingState(world) : null); copyPreviewDrew = true; } catch (_) { copyPreviewDrew = false; }
ctx.restore();
}
if (!copyPreviewDrew && typeof globalThis.drawToolItemPreview === "function") {
const previewSize = Math.max(48, Math.min(132, (item.r || itemRadiusFor?.(type, 18) || 18) * 3.4));
const buffer = document.createElement("canvas");
buffer.width = Math.ceil(previewSize);
buffer.height = Math.ceil(previewSize);
const bctx = buffer.getContext("2d");
if (bctx && globalThis.drawToolItemPreview(bctx, type, { width: buffer.width, height: buffer.height, compact: true }) !== false) {
ctx.save();
ctx.globalAlpha = blocked ? 0.44 : 0.74;
ctx.drawImage(buffer, p.x - previewSize / 2, p.y - previewSize / 2, previewSize, previewSize);
ctx.restore();
}
}
ctx.save();
ctx.globalAlpha = blocked ? 0.58 : 0.50;
ctx.setLineDash(blocked ? [5, 4] : [7, 5]);
ctx.lineWidth = blocked ? 2.2 : 2;
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.88)" : "rgba(110,178,55,0.88)";
ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)";
const rects = previewRects;
if (rects.length) {
for (const rr of rects) {
if (rr.oriented) {
ctx.save(); ctx.translate(rr.cx, rr.cy); ctx.rotate(rr.angle || 0);
roundedRect(ctx, -(rr.halfW || 10), -(rr.halfH || 5), (rr.halfW || 10) * 2, (rr.halfH || 5) * 2, 6);
ctx.fill(); ctx.stroke(); ctx.restore();
} else { roundedRect(ctx, rr.left, rr.top, rr.right - rr.left, rr.bottom - rr.top, 6); ctx.fill(); ctx.stroke(); }
}
} else {
const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16);
ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(p.x - 9, p.y); ctx.lineTo(p.x + 9, p.y); ctx.moveTo(p.x, p.y - 9); ctx.lineTo(p.x, p.y + 9); ctx.stroke();
ctx.restore();
}
return;
}
if ((world?.tool === "rope" || world?.tool === "rod") && world.pendingLinkEndpoint) {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint;
ctx.save();
ctx.globalAlpha = 0.72;
ctx.lineWidth = 3.2;
ctx.lineCap = "round";
if (world?.tool === "rope") ctx.setLineDash([8, 6]);
else ctx.setLineDash([]);
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : "rgba(142,94,45,0.88)";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "rgba(142,94,45,0.85)";
ctx.beginPath(); ctx.arc(a.x, a.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
return;
}
if (world?.tool !== "area_delete") return;
const radius = world.areaDeleteRadius?.() || 138;
ctx.save();
ctx.globalAlpha = 0.42;
ctx.lineWidth = 2.2;
ctx.setLineDash([8, 7]);
ctx.strokeStyle = "rgba(218,82,70,0.82)";
ctx.fillStyle = "rgba(218,82,70,0.10)";
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.setLineDash([3, 8]);
ctx.globalAlpha = 0.34;
ctx.beginPath();
ctx.arc(p.x, p.y, radius * 0.58, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 0.70;
ctx.beginPath();
ctx.moveTo(p.x - 10, p.y);
ctx.lineTo(p.x + 10, p.y);
ctx.moveTo(p.x, p.y - 10);
ctx.lineTo(p.x, p.y + 10);
ctx.stroke();
ctx.restore();
}
function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
if (tarinai.dead || tarinai.world.selected !== tarinai) return;
ctx.save();
ctx.globalAlpha = 0.10;
ctx.fillStyle = "rgba(178, 236, 96, 0.42)";
ctx.beginPath();
ctx.ellipse(tarinai.x, tarinai.y - drawH * 0.06, drawW * 0.58, drawH * 0.42, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawDawnRimLight(ctx, drawW, drawH, lighting) {
return;
}
// Cached static garden layer: expensive soil/decor loops are rendered only when
// canvas size, coarse light phase, or weather changes.
let activeCtx = null;
const backgroundCache = {
canvas: null,
ctx: null,
w: 0,
h: 0,
key: "",
};
function backgroundCacheKey(world, lighting) {
const weather = world.weather || "sunny";
const field = world.fieldType || "garden";
const ground = world.groundType || "soil";
// Keep the heavy garden/sky redraw coarse. Fine light changes are handled by overlays.
const lightBucket = Math.round((lighting.light || 0) * 8);
const warmthBucket = Math.round((lighting.goldenStrength || 0) * 5);
const weatherBucket = Math.floor((world.time || 0) / 8);
return `${field}:${ground}:${weather}:${lightBucket}:${warmthBucket}:${weatherBucket}`;
}
function invalidateRenderCaches(reason = "manual") {
if (backgroundCache) backgroundCache.key = "";
if (terrainCache) terrainCache.key = "";
if (terrainChunkCache) {
terrainChunkCache.key = "";
terrainChunkCache.chunks?.clear?.();
}
}
// Cached low-priority terrain layer: grass, footprints and splats are mostly
// static visual noise. Rendering them into visible chunks avoids repeating
// thousands of small strokes every frame and avoids redrawing the entire field
// when a small terrain area changes.
const terrainCache = {
canvas: null,
ctx: null,
w: 0,
h: 0,
key: "",
};
const terrainChunkCache = {
size: 256,
key: "",
chunks: new Map(),
};
function isCachedTerrainItem(it) {
if (!it || it.dead) return false;
if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true;
if (it.type === "zunchi") return false;
return false;
}
function isBehindSpriteLayerItem(it) {
return Boolean(it && !it.dead && (it.type === "rope" || it.type === "rod" || it.type === "bed" || it.type === "grass_bed" || it.type === "splat"));
}
function renderLayerSortY(entity) {
if (!entity) return 0;
if (entity.type === "bed" || entity.type === "grass_bed") return (entity.y || 0) - Math.max(10, (entity.r || 20) * 0.45);
if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return (entity.y || 0) + Math.max(6, (entity.radius || 20) * 0.28);
return entity.y || 0;
}
function renderLayerKindRank(entity) {
if (!entity) return 0;
if (entity.kind === "ant_worker" || entity.kind === "ant_queen") return 1;
if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return 2;
return 0;
}
function terrainCacheKey(worldRef, lighting) {
const lightBucket = Math.round((lighting.light || 0) * 6);
return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}:${worldRef.w}x${worldRef.h}:${lightBucket}`;
}
function terrainChunkKey(cx, cy) {
return `${cx}:${cy}`;
}
function terrainChunkFor(cx, cy, worldRef) {
const key = terrainChunkKey(cx, cy);
let chunk = terrainChunkCache.chunks.get(key);
const size = terrainChunkCache.size;
const x = cx * size;
const y = cy * size;
const w = Math.max(1, Math.min(size, Math.ceil((worldRef.w || 1) - x)));
const h = Math.max(1, Math.min(size, Math.ceil((worldRef.h || 1) - y)));
if (!chunk) {
const canvas = document.createElement("canvas");
chunk = { canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" };
terrainChunkCache.chunks.set(key, chunk);
}
if (chunk.w !== w || chunk.h !== h) {
chunk.w = w;
chunk.h = h;
chunk.dirty = true;
}
return chunk;
}
function redrawTerrainChunk(chunk, worldRef, lighting) {
const c = chunk.ctx;
chunk.canvas.width = Math.max(1, chunk.w);
chunk.canvas.height = Math.max(1, chunk.h);
c.clearRect(0, 0, chunk.w, chunk.h);
c.save();
c.translate(-chunk.x, -chunk.y);
const oldPointer = worldRef.pointer;
try {
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
const cx = chunk.x + chunk.w / 2;
const cy = chunk.y + chunk.h / 2;
const radius = Math.hypot(chunk.w, chunk.h) / 2 + 90;
const candidates = worldRef.nearbyItems ? worldRef.nearbyItems(cx, cy, radius, false) : (worldRef.items || []);
const rect = { left: chunk.x - 80, top: chunk.y - 80, right: chunk.x + chunk.w + 80, bottom: chunk.y + chunk.h + 80 };
for (const it of candidates || []) {
if (!isCachedTerrainItem(it) || !isEntityVisibleInRect(it, rect, 80)) continue;
it.draw(c, worldRef.time || 0, lighting);
}
} catch (err) {
console.error("terrain chunk redraw failed", err);
} finally {
if (oldPointer) worldRef.pointer = oldPointer;
c.restore();
}
chunk.dirty = false;
chunk.key = terrainChunkCache.key;
}
function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
const end = window.TarinaiPerf?.begin?.("render.terrain") || null;
try {
const key = terrainCacheKey(worldRef, lighting);
if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) {
terrainChunkCache.key = key;
terrainChunkCache.chunks.clear();
worldRef.terrainDirty = false;
worldRef.terrainDirtyGlobal = false;
worldRef.terrainDirtyChunks?.clear?.();
} else if (worldRef.terrainDirty && worldRef.terrainDirtyChunks?.size) {
for (const chunkKey of worldRef.terrainDirtyChunks) {
const chunk = terrainChunkCache.chunks.get(chunkKey);
if (chunk) chunk.dirty = true;
}
worldRef.terrainDirty = false;
worldRef.terrainDirtyChunks.clear();
}
const size = terrainChunkCache.size;
const minX = Math.max(0, Math.floor((visibleRect.left || 0) / size));
const maxX = Math.min(Math.floor(((worldRef.w || 1) - 1) / size), Math.floor((visibleRect.right || 0) / size));
const minY = Math.max(0, Math.floor((visibleRect.top || 0) / size));
const maxY = Math.min(Math.floor(((worldRef.h || 1) - 1) / size), Math.floor((visibleRect.bottom || 0) / size));
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const chunk = terrainChunkFor(cx, cy, worldRef);
if (chunk.dirty || chunk.key !== terrainChunkCache.key) redrawTerrainChunk(chunk, worldRef, lighting);
ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h);
}
}
} finally {
if (end) end();
}
}
function ensureTerrainCache(worldRef, lighting) {
// Compatibility fallback for older callers. The main renderer uses chunks.
const w = Math.max(1, Math.floor(worldRef.w || 1));
const h = Math.max(1, Math.floor(worldRef.h || 1));
const key = `${terrainCacheKey(worldRef, lighting)}:fallback:v${worldRef.terrainVersion || 0}`;
if (!terrainCache.canvas) {
terrainCache.canvas = document.createElement("canvas");
terrainCache.ctx = terrainCache.canvas.getContext("2d");
}
if (!worldRef.terrainDirty && terrainCache.w === w && terrainCache.h === h && terrainCache.key === key) return terrainCache.canvas;
terrainCache.w = w;
terrainCache.h = h;
terrainCache.key = key;
terrainCache.canvas.width = w;
terrainCache.canvas.height = h;
const c = terrainCache.ctx;
c.clearRect(0, 0, w, h);
const oldPointer = worldRef.pointer;
try {
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
for (const it of worldRef.items || []) if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting);
worldRef.terrainDirty = false;
return terrainCache.canvas;
} catch (err) {
console.error("terrain cache redraw failed", err);
terrainCache.key = "";
return null;
} finally {
if (oldPointer) worldRef.pointer = oldPointer;
}
}
function visibleWorldRect(worldRef, margin = 160) {
const scale = worldRef.viewScale ? worldRef.viewScale() : 1;
const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale);
const vh = (worldRef.viewportH || worldRef.h || 720) / Math.max(0.01, scale);
const cameraX = worldRef.cameraX || 0;
const cameraY = worldRef.cameraY || 0;
return {
left: cameraX - margin,
top: cameraY - margin,
right: cameraX + vw + margin,
bottom: cameraY + vh + margin,
};
}
function renderRadiusForEntity(entity) {
if (!entity) return 40;
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem?.scalar?.(entity, "railTravel", 150) || 150)));
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
if (entity.kind === "queen") return 42;
if (entity.kind === "worker") return 26;
if (entity.radius) return Math.max(56, entity.radius * 2.8);
if (entity.size) return Math.max(34, entity.size * 2.4);
return Math.max(42, (entity.r || 18) * 3.0);
}
function isEntityVisibleInRect(entity, rect, extra = 0) {
if (!entity || entity.dead) return false;
const r = renderRadiusForEntity(entity) + extra;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom;
}
function renderStackSignature(worldRef) {
return [
(worldRef.items || []).length,
(worldRef.tarinai || []).length,
(worldRef.ants || []).length,
worldRef.terrainVersion || 0,
].join(":");
}
function syncCarriedPlushieToOwner(it, worldRef) {
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return false;
const owner = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
if (!owner) return false;
const ox = it.x, oy = it.y;
it.ownerId = it.ownerId || owner.id;
it.onHead = true;
it.x = owner.x;
it.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
}
function ensureWorldRenderStack(worldRef) {
if (!worldRef._renderStack) {
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
}
const stack = worldRef._renderStack;
const signature = renderStackSignature(worldRef);
if (!worldRef.drawListDirty && stack.signature === signature) return stack;
stack.backItems.length = 0;
stack.layered.length = 0;
stack.carriedPlushies.length = 0;
stack.lodgedPins.length = 0;
for (const it of worldRef.items || []) {
if (!it || it.dead || isCachedTerrainItem(it)) continue;
if (isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
continue;
}
if (it.isStructure && it.type === "plushie" && it.carriedById) {
stack.carriedPlushies.push(it);
continue;
}
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
}
for (const t of worldRef.tarinai || []) {
if (t && !t.dead) stack.layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
}
for (const a of worldRef.ants || []) {
if (a && !a.dead) stack.layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
}
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
stack.signature = signature;
worldRef.drawListDirty = false;
return stack;
}
function collectVisibleRenderStack(worldRef, visibleRect) {
const end = window.TarinaiPerf?.begin?.("render.stackBuild") || null;
try {
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
const stack = worldRef._visibleRenderStack;
stack.backItems.length = 0;
stack.layered.length = 0;
stack.carriedPlushies.length = 0;
stack.lodgedPins.length = 0;
const seen = new Set();
const addItem = (it) => {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return;
seen.add(it);
if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
return;
}
if (it.isStructure && it.type === "plushie" && it.carriedById) {
stack.carriedPlushies.push(it);
return;
}
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
};
const addLayered = (entity) => {
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
seen.add(entity);
stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) });
};
worldRef.ensureSpatial?.("render-visible");
if (worldRef.spatial?.nearbyRectInto) {
const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
itemScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
for (const it of itemScratch) addItem(it);
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
tarinaiScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
for (const t of tarinaiScratch) addLayered(t);
const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []);
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a);
for (const it of worldRef.items || []) {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
}
} else {
for (const it of worldRef.items || []) addItem(it);
for (const t of worldRef.tarinai || []) addLayered(t);
for (const a of worldRef.ants || []) addLayered(a);
}
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
return stack;
} finally {
if (end) end();
}
}
function ensureBackgroundCache(w, h, lighting) {
const key = backgroundCacheKey(world, lighting);
if (!backgroundCache.canvas) {
backgroundCache.canvas = document.createElement("canvas");
backgroundCache.ctx = backgroundCache.canvas.getContext("2d");
}
if (backgroundCache.w === w && backgroundCache.h === h && backgroundCache.key === key) {
return backgroundCache.canvas;
}
backgroundCache.w = w;
backgroundCache.h = h;
backgroundCache.key = key;
backgroundCache.canvas.width = Math.max(1, Math.floor(w));
backgroundCache.canvas.height = Math.max(1, Math.floor(h));
const mainCtx = activeCtx;
try {
activeCtx = backgroundCache.ctx;
drawGardenBackdrop(w, h, lighting);
drawGardenBed(w, h, lighting);
return backgroundCache.canvas;
} catch (err) {
console.error("background cache redraw failed", err);
backgroundCache.key = "";
return null;
} finally {
activeCtx = mainCtx;
}
}
function drawGardenBackdrop(w, h, lighting) {
const light = lighting.light;
const sky = activeCtx.createLinearGradient(0, 0, 0, h);
const topColor = light > 0.5 ? `rgba(${Math.round(142 + light * 62 + lighting.goldenStrength * 24)}, ${Math.round(198 + light * 34 + lighting.goldenStrength * 10)}, ${Math.round(238 + light * 15 - lighting.goldenStrength * 24)}, 1)` : `rgba(${Math.round(42 + light * 78)}, ${Math.round(55 + light * 98)}, ${Math.round(95 + light * 124)}, 1)`;
const midColor = light > 0.5 ? `rgba(${Math.round(214 + light * 28 + lighting.goldenStrength * 24)}, ${Math.round(235 + light * 12 + lighting.goldenStrength * 4)}, ${Math.round(246 - light * 8 - lighting.goldenStrength * 30)}, 1)` : `rgba(${Math.round(78 + light * 90)}, ${Math.round(88 + light * 108)}, ${Math.round(128 + light * 104)}, 1)`;
const bottomColor = light > 0.5 ? `rgba(${Math.round(230 + lighting.goldenStrength * 18)}, ${Math.round(226 - lighting.goldenStrength * 10)}, ${Math.round(202 - lighting.goldenStrength * 22)}, 1)` : "rgba(74, 84, 119, 1)";
sky.addColorStop(0, topColor);
sky.addColorStop(0.48, midColor);
sky.addColorStop(1, bottomColor);
activeCtx.fillStyle = sky;
activeCtx.fillRect(0, 0, w, h);
for (let i = 0; i < 2; i++) {
const cx = randSeed(300 + i, 80, w - 80);
const cy = randSeed(420 + i, 28, h * 0.24);
const alpha = light > 0.4 ? 0.09 + lighting.noonStrength * 0.04 : 0.04;
activeCtx.save();
activeCtx.fillStyle = `rgba(255,255,255,${alpha})`;
for (let j = 0; j < 3; j++) {
activeCtx.beginPath();
activeCtx.ellipse(cx + j * 22, cy + Math.sin(j) * 5, 34, 18, 0, 0, Math.PI * 2);
activeCtx.fill();
}
activeCtx.restore();
}
if (light < 0.22) {
activeCtx.save();
activeCtx.fillStyle = `rgba(255,255,255,${0.62 + lighting.nightStrength * 0.30})`;
for (let i = 0; i < 26; i++) {
const sx = randSeed(i + Math.floor(world.time * 0.1), 40, w - 40);
const sy = randSeed(i + Math.floor(world.time * 0.3) + 12, 26, h * 0.34);
const size = randSeed(i + 910, 1.2, 2.4);
activeCtx.fillRect(sx, sy, size, size);
}
activeCtx.restore();
}
if (lighting.bloom > 0.08) {
const sunX = w * (0.15 + (1 - lighting.progress) * 0.70);
const sunY = h * (0.12 + (1 - lighting.elevation) * 0.22);
drawRadialGlow(activeCtx, sunX, sunY, Math.max(w, h) * (0.24 + lighting.goldenStrength * 0.10), "rgba(255, 220, 138, ALPHA)", lighting.bloom * 0.10);
}
if (world.weather === "cloudy") {
activeCtx.fillStyle = "rgba(218, 223, 214, 0.16)";
activeCtx.fillRect(0, 0, w, h);
} else if (world.weather === "light_rain") {
activeCtx.fillStyle = "rgba(178, 197, 208, 0.22)";
activeCtx.fillRect(0, 0, w, h);
}
}
function drawGardenBed(w, h, lighting) {
const light = lighting.light;
const fieldType = world.fieldType || "garden";
const groundType = world.groundType || "soil";
const x = 18, y = 22, bw = w - 36, bh = h - 44;
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
const borderTop = groundType === "laboratory" ? "#cfd8e6" : groundType === "ice" ? "#bfe6f4" : groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#79a9cf" : groundType === "dirt" ? "#8fb06a" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = groundType === "laboratory" ? "#8fa1ba" : groundType === "ice" ? "#77b7d2" : groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#4f79a3" : groundType === "dirt" ? "#5f7c43" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
activeCtx.fillStyle = border;
roundedRect(activeCtx, x, y, bw, bh, 34);
activeCtx.fill();
activeCtx.save();
activeCtx.globalAlpha = 0.14;
activeCtx.strokeStyle = "rgba(255,255,255,0.85)";
activeCtx.lineWidth = 2;
roundedRect(activeCtx, x + 6, y + 6, bw - 12, bh - 12, 28);
activeCtx.stroke();
activeCtx.restore();
const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14);
const palette = groundType === "concrete"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: groundType === "blanket"
? ["#ffe1ec", "#f6c6dc", "#e3a2c6"]
: groundType === "ice"
? ["#dff8ff", "#b8e7f5", "#8bcfe4"]
: groundType === "foot_massage"
? ["#d2e6f4", "#a8c8e6", "#7fa9cd"]
: groundType === "dirt"
? [lighting.goldenStrength > 0.25 ? "#d4b47d" : "#c9a46d", lighting.goldenStrength > 0.25 ? "#b89261" : "#a87948", lighting.goldenStrength > 0.25 ? "#927046" : "#7b5a38"]
: fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6");
soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd");
soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af");
activeCtx.fillStyle = soil;
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.fill();
activeCtx.save();
activeCtx.beginPath();
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.clip();
const speckCount = groundType === "laboratory" ? 18 : groundType === "ice" ? 54 : groundType === "blanket" ? 28 : groundType === "foot_massage" ? 180 : groundType === "dirt" ? 96 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
for (let i = 0; i < speckCount; i++) {
const px = randSeed(900 + i, x + 24, x + bw - 24);
const py = randSeed(1200 + i, y + 22, y + bh - 24);
const r = randSeed(1600 + i, 1.2, groundType === "foot_massage" ? 5.2 : (groundType === "dirt" ? 4.6 : 3.8));
const a = randSeed(1900 + i, groundType === "foot_massage" ? 0.065 : (groundType === "dirt" ? 0.045 : 0.03), groundType === "foot_massage" ? 0.18 : (groundType === "dirt" ? 0.13 : 0.09)) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70);
if (groundType === "foot_massage") {
const warm = i % 5 === 0;
activeCtx.fillStyle = warm
? `rgba(${Math.round(randSeed(2100 + i, 176, 222))}, ${Math.round(randSeed(2400 + i, 206, 236))}, ${Math.round(randSeed(2700 + i, 238, 255))}, ${a * 0.92})`
: `rgba(${Math.round(randSeed(2000 + i, 52, 108))}, ${Math.round(randSeed(2300 + i, 118, 184))}, ${Math.round(randSeed(2600 + i, 182, 242))}, ${a * 1.45})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, r * randSeed(3100 + i, 1.1, 2.0), r * randSeed(3400 + i, 0.65, 1.25), randSeed(3700 + i, -1.2, 1.2), 0, Math.PI * 2);
activeCtx.fill();
} else {
activeCtx.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : (groundType === "ice" ? `rgba(255,255,255,${a * 1.65})` : (groundType === "dirt" ? `rgba(${Math.round(randSeed(2000 + i, 94, 132))}, ${Math.round(randSeed(2300 + i, 66, 104))}, ${Math.round(randSeed(2600 + i, 38, 70))}, ${a})` : `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`));
activeCtx.beginPath();
activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
}
}
if (groundType === "foot_massage") {
activeCtx.save();
activeCtx.globalCompositeOperation = "multiply";
const largePatchCount = 28;
for (let i = 0; i < largePatchCount; i++) {
const px = randSeed(8100 + i, x + 30, x + bw - 30);
const py = randSeed(8400 + i, y + 30, y + bh - 32);
const rw = randSeed(8700 + i, 12, 36);
const rh = randSeed(9000 + i, 7, 22);
const alpha = (light > 0.42 ? 0.105 : 0.070) + randSeed(9300 + i, 0, 0.055);
activeCtx.fillStyle = i % 3 === 0 ? `rgba(72, 132, 190, ${alpha})` : `rgba(118, 178, 224, ${alpha})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, rw, rh, randSeed(9600 + i, -1.1, 1.1), 0, Math.PI * 2);
activeCtx.fill();
}
activeCtx.restore();
}
if (groundType === "ice") {
activeCtx.save();
activeCtx.globalAlpha = 0.20 * (light > 0.4 ? 1.0 : 0.55);
activeCtx.strokeStyle = "rgba(255,255,255,0.72)";
activeCtx.lineWidth = 1.1;
for (let i = 0; i < 22; i += 1) {
const x = randSeed(4100 + i, 30, world.w - 30);
const y = randSeed(4300 + i, 30, world.h - 30);
const len = randSeed(4500 + i, 24, 78);
const a = randSeed(4700 + i, -0.9, 0.9);
activeCtx.beginPath();
activeCtx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
activeCtx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
activeCtx.stroke();
}
activeCtx.restore();
}
if (groundType === "dirt") {
activeCtx.save();
activeCtx.globalAlpha = light > 0.42 ? 0.16 : 0.10;
activeCtx.strokeStyle = "rgba(72, 52, 32, 0.34)";
activeCtx.lineWidth = 1.0;
for (let i = 0; i < 34; i++) {
const px = randSeed(10100 + i, x + 30, x + bw - 30);
const py = randSeed(10400 + i, y + 30, y + bh - 32);
const len = randSeed(10700 + i, 8, 24);
const a = randSeed(11000 + i, -1.4, 1.4);
activeCtx.beginPath();
activeCtx.moveTo(px - Math.cos(a) * len * 0.45, py - Math.sin(a) * len * 0.45);
activeCtx.lineTo(px + Math.cos(a) * len * 0.55, py + Math.sin(a) * len * 0.55);
activeCtx.stroke();
}
activeCtx.restore();
}
if (groundType === "blanket") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.16)";
activeCtx.lineWidth = 1.25;
for (let gy = y + 38; gy < y + bh - 26; gy += 32) {
activeCtx.beginPath(); activeCtx.moveTo(x + 28, gy); activeCtx.lineTo(x + bw - 28, gy + Math.sin(gy * 0.05) * 3); activeCtx.stroke();
}
activeCtx.restore();
} else if (fieldType === "cage") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)";
activeCtx.lineWidth = 1.1;
for (let gx = x + 44; gx < x + bw - 26; gx += 44) {
activeCtx.beginPath(); activeCtx.moveTo(gx, y + 20); activeCtx.lineTo(gx, y + bh - 20); activeCtx.stroke();
}
for (let gy = y + 44; gy < y + bh - 24; gy += 44) {
activeCtx.beginPath(); activeCtx.moveTo(x + 22, gy); activeCtx.lineTo(x + bw - 22, gy); activeCtx.stroke();
}
activeCtx.restore();
} else if (fieldType === "park") {
activeCtx.save();
activeCtx.globalAlpha = light > 0.42 ? 0.24 : 0.14;
for (let i = 0; i < 24; i++) {
activeCtx.fillStyle = i % 2 ? "rgba(74, 132, 58, 0.35)" : "rgba(238, 222, 112, 0.32)";
activeCtx.beginPath();
activeCtx.ellipse(randSeed(5400 + i, x + 34, x + bw - 34), randSeed(5600 + i, y + 36, y + bh - 36), randSeed(5800 + i, 3, 8), randSeed(6000 + i, 2, 5), randSeed(6200 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
}
activeCtx.restore();
}
const patchCount = groundType === "laboratory" ? 0 : groundType === "concrete" ? 3 : groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
for (let i = 0; i < patchCount; i++) {
const px = randSeed(3300 + i, x + 40, x + bw - 40);
const py = randSeed(3600 + i, y + 44, y + bh - 46);
const rw = randSeed(3900 + i, 24, 64);
const rh = randSeed(4200 + i, 12, 28);
activeCtx.fillStyle = light > 0.42 ? `rgba(161, 186, 118, ${0.07 + lighting.goldenStrength * 0.05})` : "rgba(118, 141, 132, 0.06)";
activeCtx.beginPath();
activeCtx.ellipse(px, py, rw, rh, randSeed(4500 + i, -1, 1), 0, Math.PI * 2);
activeCtx.fill();
}
// edge grasses and tiny flowers
const edgeGrassCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
for (let i = 0; i < edgeGrassCount; i++) {
const side = i % 2;
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
const py = side === 0 ? y + 20 + randSeed(5700 + i, -4, 8) : y + bh - 24 + randSeed(6000 + i, -8, 4);
activeCtx.save();
activeCtx.translate(px, py);
activeCtx.strokeStyle = light > 0.34 ? `rgba(101, 157, 68, ${0.38 + lighting.goldenStrength * 0.16})` : "rgba(97, 126, 128, 0.25)";
activeCtx.lineWidth = 1.4;
for (let j = 0; j < 3; j++) {
const len = randSeed(6300 + i * 5 + j, 7, 14);
const dir = randSeed(6600 + i * 5 + j, -0.65, 0.65);
activeCtx.beginPath();
activeCtx.moveTo(0, 0);
activeCtx.quadraticCurveTo(Math.sin(dir) * len * 0.35, -len * 0.45, Math.sin(dir) * len, -len);
activeCtx.stroke();
}
activeCtx.restore();
}
const flowerCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
for (let i = 0; i < flowerCount; i++) {
const px = randSeed(7000 + i, x + 44, x + bw - 44);
const py = randSeed(7400 + i, y + 46, y + bh - 44);
if (i % 3 === 0) {
activeCtx.save();
activeCtx.translate(px, py);
activeCtx.globalAlpha = 0.30;
for (let p = 0; p < 4; p++) {
activeCtx.fillStyle = p === 3 ? "rgba(248, 204, 82, 0.75)" : "rgba(255,255,255,0.80)";
activeCtx.beginPath();
if (p === 3) activeCtx.arc(0, 0, 1.6, 0, Math.PI * 2);
else activeCtx.ellipse(Math.cos((p / 3) * Math.PI * 2) * 2.2, Math.sin((p / 3) * Math.PI * 2) * 2.2, 1.8, 1.3, 0, 0, Math.PI * 2);
activeCtx.fill();
}
activeCtx.restore();
}
}
const leafCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
for (let i = 0; i < leafCount; i++) {
const px = randSeed(7800 + i, x + 36, x + bw - 36);
const py = randSeed(8200 + i, y + 34, y + bh - 34);
if (i % 2 === 0) {
activeCtx.save();
activeCtx.translate(px, py);
activeCtx.rotate(randSeed(8500 + i, -1.3, 1.3));
activeCtx.fillStyle = light > 0.45 ? `rgba(160, 179, 126, ${0.22 + lighting.goldenStrength * 0.12})` : "rgba(126, 144, 139, 0.16)";
activeCtx.beginPath();
activeCtx.ellipse(-4, 0, 4.8, 2.2, -0.4, 0, Math.PI * 2);
activeCtx.ellipse(4, 0, 4.8, 2.2, 0.4, 0, Math.PI * 2);
activeCtx.ellipse(0, -4, 4.8, 2.2, 0, 0, Math.PI * 2);
activeCtx.fill();
activeCtx.restore();
} else {
activeCtx.save();
activeCtx.translate(px, py);
activeCtx.rotate(randSeed(8800 + i, -1.1, 1.1));
activeCtx.fillStyle = "rgba(158, 140, 108, 0.22)";
activeCtx.beginPath();
activeCtx.ellipse(0, 0, randSeed(9000 + i, 5, 9), randSeed(9300 + i, 2.2, 3.6), 0, 0, Math.PI * 2);
activeCtx.fill();
activeCtx.restore();
}
}
activeCtx.restore();
// subtle vignette
const vignette = activeCtx.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.22, w * 0.5, h * 0.5, Math.max(w, h) * 0.66);
vignette.addColorStop(0, "rgba(0,0,0,0)");
vignette.addColorStop(1, light > 0.4 ? `rgba(94, 70, 48, ${0.09 + lighting.goldenStrength * 0.06})` : `rgba(16, 22, 52, ${0.17 + lighting.nightStrength * 0.14})`);
activeCtx.fillStyle = vignette;
activeCtx.fillRect(0, 0, w, h);
}
function drawLightRays(ctx, w, h, lighting) {
if (lighting.quality !== "cinematic" || lighting.goldenStrength <= 0.08) return;
ctx.save();
ctx.globalCompositeOperation = "screen";
const rayAlpha = lighting.goldenStrength * LIGHTING_TUNING.rayAlpha;
const originX = w * (lighting.dawnStrength > lighting.duskStrength ? 0.08 : 0.90);
const originY = h * (0.02 + (1 - lighting.elevation) * 0.18);
for (let i = 0; i < 3; i++) {
const spread = (i - 1) * 0.20;
const g = ctx.createLinearGradient(originX, originY, w * (0.50 + spread), h * (0.58 + i * 0.06));
g.addColorStop(0, `rgba(255, 230, 170, ${rayAlpha * (1 - i * 0.10)})`);
g.addColorStop(0.30, `rgba(255, 198, 120, ${rayAlpha * 0.18})`);
g.addColorStop(1, "rgba(255, 188, 108, 0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(originX, originY);
ctx.lineTo(w * (0.10 + i * 0.28), h);
ctx.lineTo(w * (0.42 + i * 0.28), h);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawAtmosphericLighting(ctx, w, h, lighting) {
ctx.save();
if (lighting.nightStrength > 0) {
ctx.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
ctx.fillStyle = "#223683";
ctx.fillRect(0, 0, w, h);
}
if (lighting.goldenStrength > 0) {
ctx.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
ctx.fillStyle = "#ffad68";
ctx.fillRect(0, 0, w, h);
}
if (lighting.noonStrength > 0) {
ctx.globalAlpha = lighting.noonStrength * 0.035;
ctx.fillStyle = "#fff8cf";
ctx.fillRect(0, 0, w, h);
}
ctx.restore();
if (lighting.quality === "cinematic") {
drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha);
}
ctx.save();
const vignette = ctx.createRadialGradient(w * 0.5, h * 0.46, Math.min(w, h) * 0.18, w * 0.5, h * 0.48, Math.max(w, h) * 0.76);
vignette.addColorStop(0, "rgba(0,0,0,0)");
vignette.addColorStop(0.72, "rgba(0,0,0,0)");
vignette.addColorStop(1, `rgba(13, 17, 34, ${lighting.vignetteAlpha})`);
ctx.fillStyle = vignette;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
function drawRain(ctx, w, h, t) {
if (world.weather !== "light_rain") return;
ctx.save();
const count = 96;
const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites.
const slant = Math.tan(angle);
for (let i = 0; i < count; i++) {
const speed = randSeed(18100 + i, 360, 620);
const len = randSeed(18500 + i, 14, 28);
const cycleY = h + 260;
const cycleX = w + 260;
const fall = randSeed(18400 + i, -220, cycleY) + t * speed;
const y = ((fall % cycleY) + cycleY) % cycleY - 120;
const drift = fall * slant * 0.42;
const rawX = randSeed(18000 + i, -140, w + 180) + drift;
const x = ((rawX % cycleX) + cycleX) % cycleX - 130;
const alpha = y < 60 ? clamp((y + 90) / 150, 0, 1) : 1;
const dx = Math.sin(angle) * len;
const dy = Math.cos(angle) * len;
ctx.globalAlpha = (0.18 + 0.34 * randSeed(19000 + i, 0.35, 1.0)) * alpha;
ctx.strokeStyle = "rgba(88, 150, 218, 0.86)";
ctx.lineWidth = randSeed(18700 + i, 0.8, 1.45);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x - dx * 0.5, y - dy * 0.5);
ctx.lineTo(x + dx * 0.5, y + dy * 0.5);
ctx.stroke();
if (i % 4 === 0) {
ctx.globalAlpha = 0.13 * alpha;
ctx.strokeStyle = "rgba(255,255,255,0.70)";
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.moveTo(x - dx * 0.5 - 1.5, y - dy * 0.5);
ctx.lineTo(x + dx * 0.5 - 1.5, y + dy * 0.5);
ctx.stroke();
}
}
ctx.restore();
}
function weatherLabel(weather) {
return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c";
}
function renderPerfBegin(label) {
return window.TarinaiPerf?.begin?.(label) || null;
}
function renderPerfEnd(end) {
if (end) end();
}
function fillScreenFallback(ctx, w, h, lighting) {
const light = lighting?.light ?? 0.7;
ctx.save();
const bg = ctx.createLinearGradient(0, 0, 0, Math.max(1, h));
if (light > 0.42) {
bg.addColorStop(0, "#cfe3d3");
bg.addColorStop(1, "#d9d0ad");
} else {
bg.addColorStop(0, "#24355f");
bg.addColorStop(1, "#4f5d71");
}
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
function render() {
activeCtx = ctx;
const sceneW = world.w, sceneH = world.h;
const screenW = world.viewportW || sceneW;
const screenH = world.viewportH || sceneH;
const baseFieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const shakeOffset = world.currentShakeOffset ? world.currentShakeOffset() : { x: 0, y: 0 };
const fieldOffset = { x: baseFieldOffset.x + (shakeOffset.x || 0), y: baseFieldOffset.y + (shakeOffset.y || 0) };
const cameraX = world.cameraX || 0;
const cameraY = world.cameraY || 0;
const viewScale = world.viewScale ? world.viewScale() : 1;
const endSetup = renderPerfBegin("render.setup");
const lighting = getLightingState(world);
fillScreenFallback(ctx, screenW, screenH, lighting);
const light = lighting.light;
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
renderPerfEnd(endSetup);
const beginFieldTransform = () => {
ctx.save();
ctx.translate(fieldOffset.x, fieldOffset.y);
ctx.scale(viewScale, viewScale);
ctx.translate(-cameraX, -cameraY);
};
const endBackground = renderPerfBegin("render.background");
beginFieldTransform();
if (cachedBackground) {
ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH);
} else {
drawGardenBackdrop(sceneW, sceneH, lighting);
drawGardenBed(sceneW, sceneH, lighting);
}
ctx.restore();
drawLightRays(ctx, screenW, screenH, lighting);
renderPerfEnd(endBackground);
const visibleRect = visibleWorldRect(world, 180);
beginFieldTransform();
const endPreviews = renderPerfBegin("render.previews");
drawPlacementPreview(ctx, world);
drawOperationToolPreview(ctx, world);
renderPerfEnd(endPreviews);
drawTerrainLayer(ctx, world, lighting, visibleRect);
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
const renderStack = collectVisibleRenderStack(world, visibleRect);
window.TarinaiPerf?.setMetric?.("render.visibleBackItems", renderStack.backItems.length);
window.TarinaiPerf?.setMetric?.("render.visibleLayered", renderStack.layered.length);
window.TarinaiPerf?.setMetric?.("render.visibleEffects", (world.effects || []).length);
const endBackItems = renderPerfBegin("render.draw.backItems");
for (const it of renderStack.backItems) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
renderPerfEnd(endBackItems);
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
const endLayered = renderPerfBegin("render.draw.layered");
for (const entry of renderStack.layered) {
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);
}
renderPerfEnd(endLayered);
const endAttachments = renderPerfBegin("render.draw.attachments");
for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of renderStack.lodgedPins) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
renderPerfEnd(endAttachments);
drawToolTargetHoverHighlight(ctx, world);
const endEffects = renderPerfBegin("render.draw.effects");
for (const ef of world.effects) {
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
}
renderPerfEnd(endEffects);
ctx.restore();
// Light affecting the whole visible scene, including characters and ground.
const endLighting = renderPerfBegin("render.lightingWeather");
drawAtmosphericLighting(ctx, screenW, screenH, lighting);
if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
renderPerfEnd(endLighting);
const drawAtScreenPosition = (entity, drawFn) => {
if (!entity) return;
const ox = entity.x;
const oy = entity.y;
const screen = world.worldToScreen ? world.worldToScreen(ox, oy) : { x: ox, y: oy };
entity.x = screen.x;
entity.y = screen.y;
try {
drawFn();
} finally {
entity.x = ox;
entity.y = oy;
}
};
const endUiOverlay = renderPerfBegin("render.uiOverlay");
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
drawPointerItemTooltip(ctx, world);
drawShootCursorOverlay(ctx, world);
drawSelectedToolCursorIcon(ctx, world);
renderPerfEnd(endUiOverlay);
const endHud = renderPerfBegin("render.hud");
// Time HUD
ctx.save();
const hudW = 150;
const hudH = 56;
const w = screenW, h = screenH;
const hudX = w - hudW - 22;
const hudY = 16;
ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)";
roundedRect(ctx, hudX, hudY, hudW, hudH, 18);
ctx.fill();
ctx.strokeStyle = light > 0.42 ? "rgba(118,103,83,0.12)" : "rgba(206,213,242,0.15)";
ctx.stroke();
ctx.fillStyle = "#2a241d";
ctx.font = "bold 16px ui-rounded, sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${world.day}\u65e5\u76ee`, hudX + 24, hudY + 25);
ctx.font = "12px ui-rounded, sans-serif";
ctx.fillStyle = "rgba(111,101,88,0.86)";
ctx.fillText(`${world.phaseName()} / ${world.clockString()} / ${weatherLabel(world.weather)}`, hudX + 24, hudY + 45);
ctx.restore();
ctx.save();
ctx.textAlign = "right";
ctx.font = "10px ui-rounded, sans-serif";
ctx.fillStyle = light > 0.42 ? "rgba(84,72,55,0.42)" : "rgba(230,235,255,0.48)";
ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12);
ctx.restore();
renderPerfEnd(endHud);
}
function drawPointerItemTooltip(ctx, world) {
const info = world.pointerItemTooltipInfo?.();
const target = info?.target || null;
const rawLines = (info?.lines || []).filter(Boolean);
if (!target || !rawLines.length) return;
ctx.save();
ctx.font = "12px Yomogi, sans-serif";
const maxTextW = 238;
const ellipsize = (text, maxW) => {
let s = String(text || "");
if (ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > maxW) s = s.slice(0, -1);
return `${s}\u2026`;
};
const wrapLine = (text, maxW) => {
const s = String(text || "");
if (ctx.measureText(s).width <= maxW) return [s];
if (s.includes("\u3001")) {
const parts = s.split("\u3001");
const out = [];
let line = "";
for (const part of parts) {
const candidate = line ? `${line}\u3001${part}` : part;
if (ctx.measureText(candidate).width <= maxW) line = candidate;
else {
if (line) out.push(line);
line = part;
}
}
if (line) out.push(line);
return out.slice(0, 3).map(line => ellipsize(line, maxW));
}
return [ellipsize(s, maxW)];
};
let lines = [];
for (const line of rawLines) lines.push(...wrapLine(line, maxTextW));
if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("\u3001"), maxTextW)];
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
const lineH = 17;
const w = Math.min(280, Math.max(92, textW + 22));
const h = Math.max(38, 16 + lines.length * lineH);
const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y };
const canvasW = world.viewportW || world.w || ctx.canvas.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas.height || 1;
const x = clamp(pos.x - w / 2, 8, canvasW - w - 8);
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8);
ctx.fillStyle = "rgba(46,38,30,0.66)";
ctx.strokeStyle = "rgba(255,248,220,0.74)";
ctx.lineWidth = 1.2;
roundedRect(ctx, x, y, w, h, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#fff8e6";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
lines.forEach((line, i) => ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2));
ctx.restore();
}
function drawSelectedCard(ctx, world, lighting) {
const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) return;
const w = world.viewportW || world.w;
const h = world.viewportH || world.h;
const cardW = 152;
const cardH = 80;
let x = t.x + 18;
let y = t.y - 100;
x = clamp(x, 10, w - cardW - 10);
y = clamp(y, 10, h - cardH - 10);
ctx.save();
ctx.fillStyle = lighting.light > 0.42 ? "rgba(255,250,239,0.82)" : "rgba(239,243,255,0.74)";
roundedRect(ctx, x, y, cardW, cardH, 14);
ctx.fill();
ctx.strokeStyle = "rgba(84,68,48,0.12)";
ctx.stroke();
ctx.fillStyle = "rgba(42,36,29,0.86)";
ctx.font = "bold 11px ui-rounded, sans-serif";
ctx.fillText(t.name, x + 10, y + 18);
ctx.font = "10px ui-rounded, sans-serif";
const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : "";
const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35);
const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : "";
ctx.fillText(`\u7dcf\u5408\u7684\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}/${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, x + 10, y + 52);
ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68);
ctx.restore();
}
function roundedRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
window.TarinaiRender = { invalidateRenderCaches, randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, fillScreenFallback, drawSelectedCard, render, roundedRect };