"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 = toolDefinition(tool); 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 drawSimpleFireShape(ctx, x = 0, y = 0, size = 12, options = {}) { if (!ctx) return false; const s = Math.max(2, Number(size || 0) || 2); const phase = Number.isFinite(options.phase) ? options.phase : 0; const alpha = clamp(Number(options.alpha ?? 1) || 0, 0, 1); if (alpha <= 0.01) return false; const wobble = Math.sin(phase) * 0.08; ctx.save(); ctx.translate(x, y); ctx.globalAlpha *= alpha; if (options.shadow !== false) { ctx.shadowColor = options.shadowColor || "rgba(255,72,24,0.34)"; ctx.shadowBlur = Math.max(0, s * 0.34); } ctx.fillStyle = options.outer || "rgba(236,46,24,0.88)"; ctx.strokeStyle = options.edge || "rgba(255,198,72,0.70)"; ctx.lineWidth = Math.max(0.8, s * 0.08); ctx.beginPath(); ctx.moveTo(0, -s * (1.05 + wobble)); ctx.bezierCurveTo(s * (0.58 + wobble), -s * 0.55, s * 0.50, s * 0.28, s * 0.10, s * 0.62); ctx.bezierCurveTo(s * 0.05, s * 0.40, -s * 0.08, s * 0.22, -s * 0.27, s * 0.10); ctx.bezierCurveTo(-s * 0.30, s * 0.40, -s * 0.18, s * 0.56, 0, s * 0.66); ctx.bezierCurveTo(-s * 0.56, s * 0.36, -s * 0.54, -s * 0.44, 0, -s * (1.05 + wobble)); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = options.inner || "rgba(255,224,72,0.88)"; ctx.beginPath(); ctx.moveTo(s * 0.04, -s * 0.58); ctx.bezierCurveTo(s * 0.32, -s * 0.18, s * 0.24, s * 0.32, s * 0.02, s * 0.48); ctx.bezierCurveTo(-s * 0.26, s * 0.22, -s * 0.18, -s * 0.24, s * 0.04, -s * 0.58); ctx.closePath(); ctx.fill(); ctx.restore(); return true; } TARINAI_RENDER_GLOBAL.drawSimpleFireShape = drawSimpleFireShape; 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 drawWireConnectableHighlights(ctx, world) { if (!world || !["wire", "insulated_wire"].includes(String(world.tool || ""))) return; const signal = globalThis.TarinaiSignalSystem; const circuit = globalThis.TarinaiCircuitBoardSystem; if (!signal?.isSignalConnectable) return; const pulse = 0.72 + Math.sin((world.time || 0) * 7.5) * 0.13; const pointer = world.pointer || {}; ctx.save(); ctx.setLineDash([7, 5]); const blockedTypes = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]); for (const item of world.items || []) { if (!item || item.dead || item.type === "splat" || blockedTypes.has(item.type)) continue; const signalCapable = signal.isSignalConnectable(item); if (item.type === "circuit_board") { for (const port of circuit?.ports?.(item) || []) { const point = circuit.portWorld(item, port); if (!point) continue; const hovered = pointer.inside && Math.hypot(pointer.x - point.x, pointer.y - point.y) <= 17; const selected = world.pendingLinkEndpoint?.id === item.id && world.pendingLinkEndpoint?.portId === port.portId; ctx.globalAlpha = selected ? 1 : (hovered ? 0.98 : 0.72); ctx.fillStyle = port.direction === "input" ? "rgba(74,177,255,0.22)" : "rgba(255,132,65,0.22)"; ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (port.direction === "input" ? "rgba(74,177,255,0.98)" : "rgba(255,132,65,0.98)"); ctx.lineWidth = selected || hovered ? 3.8 : 2.5; ctx.beginPath(); ctx.arc(point.x, point.y, (selected || hovered ? 12 : 9) * pulse, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } continue; } const selected = world.pendingLinkEndpoint?.id === item.id; const radius = Math.max(18, Number(item.r || 22) * 1.22 + 7); ctx.globalAlpha = selected ? 1 : (signalCapable ? 0.66 : 0.30); ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (signalCapable ? "rgba(90,207,255,0.94)" : "rgba(224,232,238,0.72)"); ctx.lineWidth = selected ? 4 : (signalCapable ? 2.4 : 1.6); ctx.beginPath(); ctx.ellipse(item.x, item.y, radius, radius * 0.78, 0, 0, Math.PI * 2); ctx.stroke(); } ctx.setLineDash([]); ctx.restore(); } function drawToolTargetHoverHighlight(ctx, world) { if (!world) return; const tool = String(world.tool || ""); const mode = world.hoverGiveTarget ? "give" : (tool === "poke" ? "poke" : (tool === "pinch" ? "pinch" : (tool === "delete" ? "delete" : ""))); const target = mode === "give" ? world.hoverGiveTarget : (mode === "poke" ? world.hoverPokeTarget : (mode === "pinch" ? world.hoverGrabTarget : (mode === "delete" ? world.hoverDeleteTarget : null))); if (!target || target.dead || target.playerHeld || target._heldByPlayer) return; const p = world.pointer || {}; if (!p.inside) return; const style = mode === "give" ? { color: "rgba(224,98,142,0.98)", fill: "rgba(224,98,142,0.14)", label: "\u4e0e\u3048\u308b" } : (mode === "delete" ? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "\u524a\u9664" } : (mode === "poke" ? { color: "rgba(224,132,72,0.98)", fill: "rgba(224,132,72,0.13)", label: "\u3064\u3064\u304f" } : { 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 (["rope", "rod", "spring", "wire", "insulated_wire"].includes(target.type)) { 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" || target.type === "balloon") ? 1.30 : 1.18); strokeEllipse(target.x || 0, target.y || 0, r, r * 0.76); } } 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 = toolDefinition("shoot"); 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 = clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58); return { 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 shadowTier = TARINAI_RENDER_GLOBAL.TarinaiPerf?.shadowQualityTier?.() || "high"; if (shadowTier === "off") { return { dx: 0, dy: 0, skew: 0, flatness: 0.42, spread: 1, alpha: 0, color: "rgba(78, 65, 43, 0)" }; } 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 qualityScale = shadowTier === "low" ? 0.42 : shadowTier === "mid" ? 0.72 : 1; const length = lerp(0.62, 1.82, edgeLight) * scale * qualityScale; 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 * qualityScale, 0.02, 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; if (alpha <= 0) return false; 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) { if (!params || params.alpha <= 0) return false; 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(); return true; } 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 drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = 1) { if (!influence) return; const x = Number(influence.x || 0) || 0; const y = Number(influence.y || 0) || 0; const radius = Math.max(1, Number(influence.radius || 0) || 0); if (!(radius > 0)) return; ctx.save(); ctx.globalAlpha = (blocked ? 0.30 : 0.40) * Math.max(0.2, Number(alphaScale || 1) || 1); ctx.setLineDash([12, 9]); ctx.lineWidth = 2.0; const tempRange = influence?.kind === "temperatureEffectRange"; const hotRange = tempRange && influence?.type === "stove"; ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : (tempRange ? (hotRange ? "rgba(230,112,48,0.80)" : "rgba(74,166,230,0.82)") : "rgba(80,174,222,0.82)"); ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : (tempRange ? (hotRange ? "rgba(255,138,56,0.075)" : "rgba(74,166,230,0.075)") : "rgba(80,174,222,0.075)"); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.setLineDash([3, 9]); ctx.globalAlpha *= 0.72; ctx.beginPath(); ctx.arc(x, y, radius * 0.55, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } function placementPreviewFor(world) { return TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem.forWorld(world) || null; } function drawPlacementPreview(ctx, world) { const preview = placementPreviewFor(world); if (!preview) return; const { type, x, y, r, rect, rects, overlay, influence, blocked } = preview; 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 (overlay?.shape === "circle") { ctx.beginPath(); ctx.arc(overlay.centerX, overlay.centerY, Math.max(1, overlay.radius), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } else if (overlay?.shape === "roundedRect") { ctx.save(); ctx.translate(overlay.centerX, overlay.centerY); ctx.rotate(overlay.angle || 0); roundedRect(ctx, -overlay.halfWidth, -overlay.halfHeight, overlay.halfWidth * 2, overlay.halfHeight * 2, overlay.cornerRadius || 8); ctx.fill(); ctx.stroke(); ctx.restore(); } else 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 { ctx.beginPath(); ctx.arc(x, y, Math.max(8, r), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.setLineDash([]); if (type === "one_way_fence") { const rr = rect || (rects && rects[0]) || null; const angle = Number(rr?.angle || preview.item?.angle || 0) || 0; const nx = Math.sin(angle); const ny = -Math.cos(angle); const arrowLength = Math.max(38, Math.min(68, Number(rr?.halfW || 48) * 0.70)); const head = Math.max(10, Math.min(17, arrowLength * 0.28)); const sx = x - nx * arrowLength * 0.48; const sy = y - ny * arrowLength * 0.48; const ex = x + nx * arrowLength * 0.48; const ey = y + ny * arrowLength * 0.48; const px = -ny; const py = nx; ctx.save(); ctx.globalAlpha = blocked ? 0.68 : 0.96; ctx.strokeStyle = blocked ? "rgba(194,70,58,0.96)" : "rgba(246,252,226,0.98)"; ctx.fillStyle = blocked ? "rgba(194,70,58,0.96)" : "rgba(246,252,226,0.98)"; ctx.lineWidth = Math.max(3.2, ctx.lineWidth * 1.55); ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - nx * head + px * head * 0.62, ey - ny * head + py * head * 0.62); ctx.lineTo(ex - nx * head - px * head * 0.62, ey - ny * head - py * head * 0.62); ctx.closePath(); ctx.fill(); ctx.restore(); } const markerX = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerX : x; const markerY = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerY : y; ctx.globalAlpha = blocked ? 0.52 : 0.70; ctx.beginPath(); ctx.moveTo(markerX - 8, markerY); ctx.lineTo(markerX + 8, markerY); ctx.moveTo(markerX, markerY - 8); ctx.lineTo(markerX, markerY + 8); ctx.stroke(); ctx.restore(); } function drawPressureSwitchRangeOverlay(ctx, world) { if (!ctx || !world) return; const specs = []; const editing = world._pressureSwitchOverlay; const switches = typeof world.itemsOfType === "function" ? (world.itemsOfType("pressure_switch") || []) : (world.items || []).filter(item => item && !item.dead && item.type === "pressure_switch"); for (const item of switches) { if (!item || item.dead || item.pressureTarget === "time" || item.pressureTarget === "temperature") continue; const isEditing = editing?.item === item; specs.push({ x: Number(item.x || 0) || 0, y: Number(item.y || 0) || 0, width: Math.max(60, Math.min(840, Number(isEditing ? editing.width : item.pressureWidth || 220) || 220)), height: Math.max(60, Math.min(840, Number(isEditing ? editing.height : item.pressureHeight || 160) || 160)), active: Boolean(item.signalActive), preview: false, shape: "rect", }); } if (world.tool === "pressure_switch" && world.pointer?.inside) { specs.push({ x: Number(world.pointer.x || 0) || 0, y: Number(world.pointer.y || 0) || 0, width: 220, height: 160, active: false, preview: true, shape: "rect", }); } if (!specs.length) return; const line = world.screenSizeToWorld ? world.screenSizeToWorld(2.0) : 2.0; ctx.save(); for (const spec of specs) { const color = spec.active ? "rgba(255,196,50,0.88)" : "rgba(72,166,232,0.74)"; const fill = spec.active ? "rgba(255,196,50,0.075)" : "rgba(72,166,232,0.055)"; ctx.fillStyle = fill; ctx.strokeStyle = color; ctx.lineWidth = Math.max(1.2, line); ctx.setLineDash(spec.preview ? [10, 7] : [8, 8]); ctx.beginPath(); if (spec.shape === "circle") ctx.arc(spec.x, spec.y, spec.radius, 0, Math.PI * 2); else ctx.rect(spec.x - spec.width * 0.5, spec.y - spec.height * 0.5, spec.width, spec.height); ctx.fill(); ctx.stroke(); } ctx.setLineDash([]); 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 placementInfo = TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem.forItem(world, item) || null; const previewRects = placementInfo?.rects || world.solidObstacleRects?.(item) || []; const previewOverlay = placementInfo?.overlay || null; const blocked = Boolean(placementInfo ? placementInfo.blocked : (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 (previewOverlay?.shape === "ellipse") { ctx.beginPath(); ctx.ellipse(previewOverlay.centerX, previewOverlay.centerY, Math.max(1, previewOverlay.radiusX), Math.max(1, previewOverlay.radiusY), 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } else if (previewOverlay?.shape === "roundedRect") { ctx.save(); ctx.translate(previewOverlay.centerX, previewOverlay.centerY); ctx.rotate(previewOverlay.angle || 0); roundedRect(ctx, -previewOverlay.halfWidth, -previewOverlay.halfHeight, previewOverlay.halfWidth * 2, previewOverlay.halfHeight * 2, previewOverlay.cornerRadius || 8); ctx.fill(); ctx.stroke(); ctx.restore(); } else 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.arc(item.x, item.y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.setLineDash([]); const markerX = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerX : item.x; const markerY = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerY : item.y; ctx.beginPath(); ctx.moveTo(markerX - 9, markerY); ctx.lineTo(markerX + 9, markerY); ctx.moveTo(markerX, markerY - 9); ctx.lineTo(markerX, markerY + 9); ctx.stroke(); ctx.restore(); } return; } if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(world?.tool) && 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" || world?.tool === "wire" || world?.tool === "insulated_wire") ctx.setLineDash([8, 6]); else ctx.setLineDash([]); ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : (world?.tool === "spring" ? "rgba(105,112,124,0.92)" : ((world?.tool === "wire" || world?.tool === "insulated_wire") ? "rgba(232,196,64,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 === "water_hose") { const radius = world.waterHoseRadius?.() || 138; ctx.save(); ctx.globalAlpha = 0.48; ctx.lineWidth = 2.1; ctx.setLineDash([8, 7]); ctx.strokeStyle = "rgba(72,166,216,0.84)"; ctx.fillStyle = "rgba(72,166,216,0.10)"; ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 0.72; 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(); 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(); } // 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 backgroundTimePhase(world) { const progress = typeof world?.dayProgress === "function" ? world.dayProgress() : (((world?.time || 0) % CONFIG.dayLength) / CONFIG.dayLength); const hour = ((progress % 1) + 1) % 1 * 24; if (hour >= 5 && hour < 10) return "morning"; if (hour >= 10 && hour < 17) return "day"; if (hour >= 17 && hour < 20) return "evening"; return "night"; } function backgroundCacheKey(world, lighting) { const weather = world.weather || "sunny"; const field = world.fieldType || "garden"; const ground = world.groundType || "soil"; const phase = backgroundTimePhase(world); // 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); return `${field}:${ground}:${weather}:${phase}:${lightBucket}:${warmthBucket}`; } function invalidateRenderCaches(reason = "manual") { if (backgroundCache) backgroundCache.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 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; } const LINK_RENDER_TYPES = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire"]); function isLinkRenderItem(it) { return Boolean(it && !it.dead && LINK_RENDER_TYPES.includes(it.type)); } function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) { if (!isLinkRenderItem(it) || !worldRef || !rect) return false; const runtime = window.TarinaiLinkRuntime; const pb = window.TarinaiPhysicsBodySystem; const points = []; const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef); const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), worldRef); if (a) points.push(a); if (b) points.push(b); if (it.type === "rope") { const particles = pb?.particles?.(it); if (Array.isArray(particles)) { for (const point of particles) if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) points.push(point); } } if (points.length < 2) return false; let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity; for (const point of points) { left = Math.min(left, Number(point.x) || 0); right = Math.max(right, Number(point.x) || 0); top = Math.min(top, Number(point.y) || 0); bottom = Math.max(bottom, Number(point.y) || 0); } const pad = Math.max(4, Number(margin) || 18); return right + pad >= rect.left && left - pad <= rect.right && bottom + pad >= rect.top && top - pad <= rect.bottom; } function isBehindSpriteLayerItem(it) { return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "seesaw")); } function renderLayerSortY(entity) { if (!entity) return 0; const y = Number.isFinite(Number(entity.y)) ? Number(entity.y) : 0; // Layering should be decided by the bottom edge/ground contact, not by the center. // This prevents tall or large items from flipping in front/behind too early. if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) { return y + Math.max(8, (Number(entity.radius) || 20) * 0.86); } if (entity.kind === "queen" || entity.kind === "worker") return y + Math.max(4, Number(entity.size || 10) || 10); const type = String(entity.type || ""); const r = Math.max(0, Number(entity.r || entity.radius || entity.size || 0) || 0); if (type === "bed" || type === "grass_bed" || type === "toilet") return y + Math.max(8, r * 0.72); if (type === "nest_box") return y + Math.max(10, r * 0.76); if (type === "pipe") return y + Math.max(8, r * 0.64); if (type === "fan") return y + Math.max(10, r * 0.95); if (type === "rotator" || type === "reciprocator" || type === "poison_block") { const reach = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || r; return y + Math.max(r, reach * 0.32); } if (type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") { return y + Math.max(8, r * 0.52); } return y + Math.max(0, r); } function renderLayerKindRank(entity) { if (!entity) return 0; 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"); 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 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; const type = String(entity.type || ""); const dynamicRadius = type === "genkotsu" || type === "reciprocator" || type === "rotator" || type === "poison_block"; if (!dynamicRadius && Number.isFinite(entity._renderCullRadius)) return entity._renderCullRadius; let value; if (type === "genkotsu") value = Math.max(420, (entity.r || 88) * 6.2); else if (type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") value = Math.max(96, (entity.r || 24) * 4.2); else if (type === "reciprocator") value = Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.reach(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem.scalar(entity, "railTravel", 150) || 150))); else if (type === "nest_box") value = Math.max(86, (entity.r || 34) * 3.2); else if (type === "pipe") value = Math.max(78, (entity.r || 36) * 2.8); else if (type === "ant_nest") value = Math.max(72, (entity.r || 28) * 3.0); else if (entity.kind === "queen") value = 42; else if (entity.kind === "worker") value = 26; else if (entity.radius) value = Math.max(56, entity.radius * 2.8); else if (entity.size) value = Math.max(34, entity.size * 2.4); else value = Math.max(42, (entity.r || 18) * 3.0); if (!dynamicRadius && !entity.kind && !(typeof Tarinai !== "undefined" && entity instanceof Tarinai)) entity._renderCullRadius = value; return value; } function isPointVisibleInRect(x, y, radius, rect) { const r = Math.max(0, Number(radius || 0) || 0); const px = Number.isFinite(x) ? x : 0; const py = Number.isFinite(y) ? y : 0; return px + r >= rect.left && px - r <= rect.right && py + r >= rect.top && py - r <= rect.bottom; } function isEntityVisibleInRect(entity, rect, extra = 0) { if (!entity || entity.dead) return false; return isPointVisibleInRect(entity.x, entity.y, renderRadiusForEntity(entity) + extra, rect); } function carriedPlushieRenderPose(it, worldRef) { if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return null; const owner = worldRef.liveTarinaiById?.(it.carriedById) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById); if (!owner) return null; return { owner, x: owner.x, y: owner.y - Math.max(22, (owner.radius || 24) * 1.02) }; } function drawCarriedPlushieAtOwner(it, worldRef, ctx, time, lighting) { const pose = carriedPlushieRenderPose(it, worldRef); if (!pose) return false; ctx.save(); ctx.translate((pose.x || 0) - (it.x || 0), (pose.y || 0) - (it.y || 0)); it.draw(ctx, time, lighting); ctx.restore(); return true; } function resetScratchSet(set) { if (set && typeof set.clear === "function") { set.clear(); return set; } return new Set(); } function resetScratchMap(map) { if (map && typeof map.clear === "function") { map.clear(); return map; } return new Map(); } function pushRenderLayerEntry(stack, entity) { const pool = stack._layeredPool || (stack._layeredPool = []); const index = stack.layered.length; const entry = pool[index] || (pool[index] = { entity: null, y: 0, rank: 0 }); entry.entity = entity; entry.y = renderLayerSortY(entity); entry.rank = renderLayerKindRank(entity); stack.layered.push(entry); return entry; } function compareRenderEntries(a, b) { return a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)); } function mergeSortedRenderEntries(a, b, out) { out.length = 0; let i = 0, j = 0; while (i < a.length && j < b.length) out.push(compareRenderEntries(a[i], b[j]) <= 0 ? a[i++] : b[j++]); while (i < a.length) out.push(a[i++]); while (j < b.length) out.push(b[j++]); return out; } function compareBackItems(a, b) { return (a._renderSortY ?? renderLayerSortY(a)) - (b._renderSortY ?? renderLayerSortY(b)) || (a.x || 0) - (b.x || 0); } function mergeSortedBackItems(a, b, out) { out.length = 0; let i = 0, j = 0; while (i < a.length && j < b.length) out.push(compareBackItems(a[i], b[j]) <= 0 ? a[i++] : b[j++]); while (i < a.length) out.push(a[i++]); while (j < b.length) out.push(b[j++]); return out; } function collectVisibleRenderStack(worldRef, visibleRect, creatureSortRect = visibleRect) { const end = window.TarinaiPerf.begin("render.stackBuild"); try { if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [], champions: [] }; const stack = worldRef._visibleRenderStack; stack.backItems.length = 0; stack.layered.length = 0; stack.carriedPlushies.length = 0; stack.lodgedPins.length = 0; if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0; if (stack.champions) stack.champions.length = 0; const seen = worldRef._renderVisibleSeen = resetScratchSet(worldRef._renderVisibleSeen); const staticCache = worldRef._renderStaticVisibleCache || (worldRef._renderStaticVisibleCache = { key: "", backItems: [], layered: [] }); const rectKey = `${Math.floor(visibleRect.left / 48)},${Math.floor(visibleRect.top / 48)},${Math.ceil(visibleRect.right / 48)},${Math.ceil(visibleRect.bottom / 48)}`; const staticKey = `${worldRef.renderStaticVersion || 0}:${rectKey}`; const dynamicBack = worldRef._renderDynamicBack || (worldRef._renderDynamicBack = []); const dynamicLayered = worldRef._renderDynamicLayered || (worldRef._renderDynamicLayered = []); const dynamicLayerPool = worldRef._renderDynamicLayerPool || (worldRef._renderDynamicLayerPool = []); dynamicBack.length = 0; dynamicLayered.length = 0; const addItemTo = (it, backTarget, layeredTarget) => { if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it)) return; const pose = it.isStructure && it.type === "plushie" && it.carriedById ? carriedPlushieRenderPose(it, worldRef) : null; const visible = pose ? isPointVisibleInRect(pose.x, pose.y, renderRadiusForEntity(it), visibleRect) : (isEntityVisibleInRect(it, visibleRect) || (isLinkRenderItem(it) && linkIntersectsVisibleRect(it, worldRef, visibleRect))); if (!visible) return; seen.add(it); if (isPinType(it.type) && it.pinState === "lodged") { stack.lodgedPins.push(it); return; } if (isPinType(it.type) && it.pinState === "ball_lodged") { (stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it); return; } if (pose) { stack.carriedPlushies.push(it); return; } if (isBehindSpriteLayerItem(it)) { it._renderSortY = renderLayerSortY(it); backTarget.push(it); } else { const entry = { entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) }; layeredTarget.push(entry); } }; const addDynamicEntity = (entity) => { if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return; seen.add(entity); const index = dynamicLayered.length; const entry = dynamicLayerPool[index] || (dynamicLayerPool[index] = { entity: null, y: 0, rank: 0 }); entry.entity = entity; entry.y = renderLayerSortY(entity); entry.rank = renderLayerKindRank(entity); dynamicLayered.push(entry); if (entity.isTarinaiChampion && typeof entity.drawChampionCrown === "function") (stack.champions || (stack.champions = [])).push(entity); }; worldRef.ensureSpatial?.("render-visible"); if (worldRef.spatial?.nearbyRectInto) { if (staticCache.key !== staticKey) { staticCache.key = staticKey; staticCache.backItems.length = 0; staticCache.layered.length = 0; const staticScratch = worldRef._renderVisibleStaticScratch || (worldRef._renderVisibleStaticScratch = []); const cacheRect = { left: visibleRect.left - 52, top: visibleRect.top - 52, right: visibleRect.right + 52, bottom: visibleRect.bottom + 52 }; staticScratch.length = 0; worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, cacheRect, staticScratch); const oldVisibleRect = visibleRect; visibleRect = cacheRect; for (const it of staticScratch) addItemTo(it, staticCache.backItems, staticCache.layered); visibleRect = oldVisibleRect; staticCache.backItems.sort(compareBackItems); staticCache.layered.sort(compareRenderEntries); seen.clear(); } for (const it of staticCache.backItems) seen.add(it); for (const entry of staticCache.layered) seen.add(entry.entity); const dynamicScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []); dynamicScratch.length = 0; if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, dynamicScratch); for (const it of dynamicScratch) addItemTo(it, dynamicBack, dynamicLayered); const linkScratch = worldRef._renderVisibleLinkScratch || (worldRef._renderVisibleLinkScratch = []); linkScratch.length = 0; if (worldRef.spatial.linkRenderCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.linkRenderCells, visibleRect, linkScratch); for (const it of linkScratch) addItemTo(it, dynamicBack, dynamicLayered); const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []); tarinaiScratch.length = 0; worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, creatureSortRect, tarinaiScratch); for (const t of tarinaiScratch) { if (!t || t.dead || seen.has(t) || !isEntityVisibleInRect(t, creatureSortRect)) continue; addDynamicEntity(t); } const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []); antScratch.length = 0; worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, creatureSortRect, antScratch); for (const a of antScratch) { if (!a || a.dead || seen.has(a) || !isEntityVisibleInRect(a, creatureSortRect)) continue; addDynamicEntity(a); } if (!Array.isArray(worldRef.carriedPlushies) || (worldRef.time || 0) >= (worldRef._nextCarriedPlushieRefreshAt || 0)) { const plushies = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (worldRef.items || []); worldRef.carriedPlushies = worldRef.carriedPlushies || []; worldRef.carriedPlushies.length = 0; for (const it of plushies || []) if (it && !it.dead && it.isStructure && it.type === "plushie" && it.carriedById) worldRef.carriedPlushies.push(it); worldRef._nextCarriedPlushieRefreshAt = (worldRef.time || 0) + 0.45; } for (const it of worldRef.carriedPlushies || []) addItemTo(it, dynamicBack, dynamicLayered); } else { for (const it of worldRef.items || []) addItemTo(it, dynamicBack, dynamicLayered); for (const t of worldRef.tarinai || []) if (isEntityVisibleInRect(t, creatureSortRect)) addDynamicEntity(t); for (const a of worldRef.ants || []) if (isEntityVisibleInRect(a, creatureSortRect)) addDynamicEntity(a); } // Native V8 sorting is faster than maintaining a previous-order Map plus // insertion sort for these already-culled render lists. Keep the hot path // allocation-free and let the engine optimize the comparator. dynamicBack.sort(compareBackItems); dynamicLayered.sort(compareRenderEntries); mergeSortedBackItems(staticCache.key === staticKey ? staticCache.backItems : [], dynamicBack, stack.backItems); mergeSortedRenderEntries(staticCache.key === staticKey ? staticCache.layered : [], dynamicLayered, stack.layered); 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 grass decorations are intentionally disabled; real grass is rendered through item/terrain systems. 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.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 redrawAtmosphericOverlay(w, h, lighting, tick) { if (!atmosphereOverlayCache.canvas) { atmosphereOverlayCache.canvas = document.createElement("canvas"); atmosphereOverlayCache.ctx = atmosphereOverlayCache.canvas.getContext("2d"); } const cw = Math.max(1, Math.ceil(w)); const ch = Math.max(1, Math.ceil(h)); if (atmosphereOverlayCache.w !== cw || atmosphereOverlayCache.h !== ch) { atmosphereOverlayCache.w = cw; atmosphereOverlayCache.h = ch; atmosphereOverlayCache.canvas.width = cw; atmosphereOverlayCache.canvas.height = ch; } const c = atmosphereOverlayCache.ctx; c.clearRect(0, 0, cw, ch); c.save(); if (lighting.nightStrength > 0) { c.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha; c.fillStyle = "#223683"; c.fillRect(0, 0, cw, ch); } if (lighting.goldenStrength > 0) { c.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha; c.fillStyle = "#ffad68"; c.fillRect(0, 0, cw, ch); } if (lighting.noonStrength > 0) { c.globalAlpha = lighting.noonStrength * 0.035; c.fillStyle = "#fff8cf"; c.fillRect(0, 0, cw, ch); } c.restore(); drawRadialGlow(c, cw * 0.5, ch * 0.46, Math.max(cw, ch) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha); c.save(); const vignette = c.createRadialGradient(cw * 0.5, ch * 0.46, Math.min(cw, ch) * 0.18, cw * 0.5, ch * 0.48, Math.max(cw, ch) * 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})`); c.fillStyle = vignette; c.fillRect(0, 0, cw, ch); c.restore(); atmosphereOverlayCache.tick = tick; } function drawAtmosphericLighting(ctx, w, h, lighting) { // Full-screen gradients are expensive on large/high-DPI canvases. Lighting // changes slowly, so rebuild the overlay at 8 Hz and reuse one composited image. const tick = Math.floor((world?.time || 0) * 8); if (!atmosphereOverlayCache.canvas || atmosphereOverlayCache.w !== Math.ceil(w) || atmosphereOverlayCache.h !== Math.ceil(h) || atmosphereOverlayCache.tick !== tick) { redrawAtmosphericOverlay(w, h, lighting, tick); } ctx.drawImage(atmosphereOverlayCache.canvas, 0, 0, w, h); } function drawRain(ctx, w, h, t, opts = {}) { if (world.weather !== "light_rain") return; ctx.save(); const rainAlphaScale = Math.max(0.25, Math.min(1.25, Number(opts.alphaScale ?? 1) || 1)); const count = Math.round(118 * rainAlphaScale); 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.22 + 0.40 * randSeed(19000 + i, 0.35, 1.0)) * alpha * rainAlphaScale; 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.16 * alpha * rainAlphaScale; 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 renderPerfBegin(label) { return window.TarinaiPerf.begin(label); } function renderPerfEnd(end) { if (end) end(); } function renderVisualLevel(kind, fallback = "high") { return TARINAI_RENDER_GLOBAL.TarinaiPerf?.visualLevel?.(kind, fallback) || fallback; } function visualEffectLimit(level) { if (level === "off") return 0; return Math.max(0, Number(TARINAI_RENDER_GLOBAL.TarinaiPerf?.performanceProfile?.().effectDrawBudget || 96)); } const screenFallbackCache = { canvas: null, ctx: null, w: 0, h: 0, key: "" }; const atmosphereOverlayCache = { canvas: null, ctx: null, w: 0, h: 0, tick: -1 }; function fillScreenFallback(ctx, w, h, lighting) { const light = lighting?.light ?? 0.7; const key = `${Math.round(w)}x${Math.round(h)}:${light > 0.42 ? "day" : "night"}`; if (!screenFallbackCache.canvas) { screenFallbackCache.canvas = document.createElement("canvas"); screenFallbackCache.ctx = screenFallbackCache.canvas.getContext("2d"); } if (screenFallbackCache.key !== key) { const cw = Math.max(1, Math.round(w)); const ch = Math.max(1, Math.round(h)); screenFallbackCache.w = cw; screenFallbackCache.h = ch; screenFallbackCache.key = key; screenFallbackCache.canvas.width = cw; screenFallbackCache.canvas.height = ch; const c = screenFallbackCache.ctx; const bg = c.createLinearGradient(0, 0, 0, ch); if (light > 0.42) { bg.addColorStop(0, "#cfe3d3"); bg.addColorStop(1, "#d9d0ad"); } else { bg.addColorStop(0, "#24355f"); bg.addColorStop(1, "#4f5d71"); } c.fillStyle = bg; c.fillRect(0, 0, cw, ch); } ctx.drawImage(screenFallbackCache.canvas, 0, 0, w, h); } 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); const fieldLeft = fieldOffset.x - cameraX * viewScale; const fieldTop = fieldOffset.y - cameraY * viewScale; const fieldRight = fieldLeft + sceneW * viewScale; const fieldBottom = fieldTop + sceneH * viewScale; const fieldCoversScreen = fieldLeft <= 0 && fieldTop <= 0 && fieldRight >= screenW && fieldBottom >= screenH; if (!fieldCoversScreen) 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(); if (renderVisualLevel("effects") !== "off" && renderVisualLevel("details") !== "low") { drawLightRays(ctx, screenW, screenH, lighting); } renderPerfEnd(endBackground); const visibleRect = visibleWorldRect(world, 180); // Terrain and large item effects keep the wider margin, while moving creatures // only enter the depth-sort list near the actual viewport. Their own render // radius still extends the cull test beyond this smaller margin. const creatureSortRect = visibleWorldRect(world, 72); 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, creatureSortRect); window.TarinaiPerf.setMetric("render.visibleBackItems", renderStack.backItems.length); window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length); const endBackItems = renderPerfBegin("render.draw.backItems"); for (const it of renderStack.backItems) { it.draw(ctx, world.time, lighting); } renderPerfEnd(endBackItems); // Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai. const drawnAttachedPinIds = world._renderDrawnAttachedPinIds = resetScratchSet(world._renderDrawnAttachedPinIds); const drawAttachedPinsForEntity = (entity) => { if (!entity) return; if (TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity) { TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity({ renderStack, entity, visibleRect, worldRef: world, time: world.time, drawnIds: drawnAttachedPinIds, isVisible: isEntityVisibleInRect, ctx, lighting }); return; } const isBall = entity.type === "ball"; const isTarinaiEntity = typeof Tarinai !== "undefined" && entity instanceof Tarinai; if (isBall) { for (const it of renderStack.ballLodgedPins || []) { if (!it || drawnAttachedPinIds.has(it.id) || it.pinBallId !== entity.id) continue; it.draw(ctx, world.time, lighting); drawnAttachedPinIds.add(it.id); } } if (isTarinaiEntity) { for (const it of renderStack.lodgedPins || []) { if (!it || drawnAttachedPinIds.has(it.id) || it.pinTargetId !== entity.id) continue; it.draw(ctx, world.time, lighting); drawnAttachedPinIds.add(it.id); } } }; const carriedPlushiesByOwner = world._renderCarriedPlushiesByOwner = resetScratchMap(world._renderCarriedPlushiesByOwner); const carriedOwnerLists = world._renderCarriedOwnerLists || (world._renderCarriedOwnerLists = []); let carriedOwnerListCount = 0; for (const it of renderStack.carriedPlushies) { if (!it?.carriedById) continue; let list = carriedPlushiesByOwner.get(it.carriedById); if (!list) { list = carriedOwnerLists[carriedOwnerListCount] || (carriedOwnerLists[carriedOwnerListCount] = []); carriedOwnerListCount += 1; list.length = 0; carriedPlushiesByOwner.set(it.carriedById, list); } list.push(it); } const endLayered = renderPerfBegin("render.draw.layered"); for (const entry of renderStack.layered) { entry.entity.draw(ctx, world.time, lighting); drawAttachedPinsForEntity(entry.entity); // A carried plushie shares its owner's depth position instead of being // rendered above every entity as a global attachment layer. for (const it of carriedPlushiesByOwner.get(entry.entity.id) || []) { drawCarriedPlushieAtOwner(it, world, ctx, world.time, lighting); } } renderPerfEnd(endLayered); const endAttachments = renderPerfBegin("render.draw.attachments"); for (const t of renderStack.champions || []) { t.drawChampionCrown(ctx, world.time, lighting); } for (const it of renderStack.ballLodgedPins || []) { if (!it || drawnAttachedPinIds.has(it.id)) continue; it.draw(ctx, world.time, lighting); drawnAttachedPinIds.add(it.id); } for (const it of renderStack.lodgedPins) { if (!it || drawnAttachedPinIds.has(it.id)) continue; it.draw(ctx, world.time, lighting); drawnAttachedPinIds.add(it.id); } renderPerfEnd(endAttachments); drawWireConnectableHighlights(ctx, world); drawToolTargetHoverHighlight(ctx, world); const endEffects = renderPerfBegin("render.draw.effects"); const effectLevel = renderVisualLevel("effects"); let drawnEffects = 0; const maxEffects = visualEffectLimit(effectLevel); const effects = world.effects || []; const totalEffects = effects.length; let effectScanCap = 0; if (totalEffects > 0 && maxEffects > 0) { const scanCap = totalEffects > 180 ? Math.min(totalEffects, Math.max(maxEffects * 8, 160)) : totalEffects; effectScanCap = scanCap; let cursor = totalEffects > 180 ? (Math.max(0, Math.floor(world._renderEffectCursor || 0)) % totalEffects) : 0; for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) { const ef = effects[cursor]; cursor = (cursor + 1) % totalEffects; if (isEntityVisibleInRect(ef, visibleRect)) { ef.draw(ctx); drawnEffects += 1; } } if (totalEffects > 180) world._renderEffectCursor = cursor; } if (window.TarinaiPerf?.diagnosticsEnabled?.() === true) { world._effectRenderStats = { total: totalEffects, drawn: drawnEffects, skipped: Math.max(0, totalEffects - drawnEffects), budget: maxEffects, scanCap: effectScanCap, level: effectLevel, }; window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects); window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects); window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects)); } else world._effectRenderStats = null; renderPerfEnd(endEffects); drawPressureSwitchRangeOverlay(ctx, world); ctx.restore(); // Light affecting the whole visible scene, including characters and ground. const endLighting = renderPerfBegin("render.lightingWeather"); if (renderVisualLevel("details") !== "low") drawAtmosphericLighting(ctx, screenW, screenH, lighting); if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time, { alphaScale: effectLevel === "off" ? 0.42 : 1 }); renderPerfEnd(endLighting); const endUiOverlay = renderPerfBegin("render.uiOverlay"); drawPointerItemTooltip(ctx, world); drawShootCursorOverlay(ctx, world); drawSelectedToolCursorIcon(ctx, world); renderPerfEnd(endUiOverlay); const endHud = renderPerfBegin("render.hud"); // Time HUD ctx.save(); const hudH = 50; const w = screenW, h = screenH; const hudY = 16; const seasonDay = world.seasonDayString?.() || `${world.day}\u65E5`; const playDay = `${Math.max(1, Number(world.day || 1) || 1)}\u65E5\u76EE`; const hudTemp = world.currentTemperature ?? world.updateTemperature?.(0) ?? 15; const hudLine1 = `${seasonDay} ${playDay} ${world.clockString()}`; const hudLine2 = `${world.phaseName()} ${weatherLabel(world.weather)} ${Math.round(hudTemp)}\u2103`; ctx.font = "bold 13px ui-rounded, sans-serif"; const line1W = ctx.measureText(hudLine1).width; ctx.font = "11px ui-rounded, sans-serif"; const line2W = ctx.measureText(hudLine2).width; const hudW = Math.ceil(Math.min(Math.max(104, w - 44), Math.max(line1W, line2W) + 20)); const hudX = w - hudW - 22; ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)"; roundedRect(ctx, hudX, hudY, hudW, hudH, 14); 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 13px ui-rounded, sans-serif"; ctx.textAlign = "left"; ctx.fillText(hudLine1, hudX + 10, hudY + 20); ctx.font = "11px ui-rounded, sans-serif"; ctx.fillStyle = "rgba(111,101,88,0.86)"; ctx.fillText(hudLine2, hudX + 10, hudY + 38); 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 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 isTarinaiTooltip = info?.kind === "tarinai"; const isPipeTooltip = info?.kind === "pipe"; const maxTextW = (isTarinaiTooltip || isPipeTooltip) ? Math.max(120, canvasW - 38) : 238; const ellipsize = (text, maxW) => canvasEllipsizeText(ctx, text, maxW); const wrapLine = (text, maxW) => { const s = String(text || ""); if (isTarinaiTooltip) return [ellipsize(s, maxW)]; 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 (!isTarinaiTooltip && lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("\u3001"), maxTextW)]; if (isTarinaiTooltip) lines = lines.slice(0, 4); const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); const lineH = 17; const maxBoxW = (isTarinaiTooltip || isPipeTooltip) ? Math.max(120, canvasW - 16) : 280; const w = Math.min(maxBoxW, Math.max(92, textW + 22)); const h = Math.max(38, 16 + lines.length * lineH); const x = clamp(pos.x - w / 2, 8, canvasW - w - 8); const targetGapScale = 1.45; const y = clamp(pos.y - (target.r || 24) * targetGapScale - 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"; const separator = info?.kind === "tarinai"; lines.forEach((line, i) => { ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2); if (separator && i < lines.length - 1) { ctx.save(); ctx.globalAlpha = 0.38; ctx.strokeStyle = "rgba(255,248,220,0.62)"; ctx.lineWidth = 0.8; const sy = y + 11 + lineH * (i + 1); ctx.beginPath(); ctx.moveTo(x + 10, sy); ctx.lineTo(x + w - 10, sy); ctx.stroke(); ctx.restore(); } }); ctx.restore(); } window.TarinaiRender = { invalidateRenderCaches };