"use strict"; 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 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 ((type === "fence_v" || type === "fence_h") && world.fenceRect) { const rawRect = world.fenceRect(tmp); return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(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.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, 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 (rect) { roundedRect(ctx, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8); ctx.fill(); ctx.stroke(); 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 === "water_bowl" || 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 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 perf = world.performanceLevel ? world.performanceLevel() : 0; const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180; const progressBucket = Math.round((lighting.progress || 0) * step); const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2)); return `${field}:${weather}:${progressBucket}:${weatherBucket}`; } // Cached low-priority terrain layer: grass, footprints and splats are mostly // static visual noise. Rendering them into an offscreen layer avoids repeating // thousands of small strokes every frame while preserving simulation behavior. const terrainCache = { canvas: null, ctx: null, w: 0, h: 0, key: "", }; 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 Math.hypot(it.vx || 0, it.vy || 0) < 0.08; return false; } function terrainCacheKey(worldRef, lighting) { const perf = worldRef.performanceLevel ? worldRef.performanceLevel() : 0; const lightBucket = Math.round((lighting.light || 0) * (perf >= 2 ? 8 : 14)); const version = worldRef.terrainVersion || 0; return `${worldRef.fieldType || "garden"}:${worldRef.w}x${worldRef.h}:${lightBucket}:v${version}`; } function ensureTerrainCache(worldRef, lighting) { 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); 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 { // Pointer hover rings should not be baked into the cache. 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") return Math.max(96, (entity.r || 24) * 4.2); 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 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 x = 18, y = 22, bw = w - 36, bh = h - 44; const border = activeCtx.createLinearGradient(0, y, 0, y + bh); const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66"); const borderBottom = 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 = 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 = 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, 3.8); const a = randSeed(1900 + i, 0.03, 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70); activeCtx.fillStyle = `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 (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 = 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 = 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 = 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 = 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 perf = world.performanceLevel ? world.performanceLevel() : 0; const count = perf >= 2 ? 64 : 138; 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 (perf < 3 && 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 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 lighting = getLightingState(world); fillScreenFallback(ctx, screenW, screenH, lighting); const light = lighting.light; const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting); const beginFieldTransform = () => { ctx.save(); ctx.translate(fieldOffset.x, fieldOffset.y); ctx.scale(viewScale, viewScale); ctx.translate(-cameraX, -cameraY); }; beginFieldTransform(); if (cachedBackground) { ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH); } else { drawGardenBackdrop(sceneW, sceneH, lighting); drawGardenBed(sceneW, sceneH, lighting); } ctx.restore(); const perf = world.performanceLevel ? world.performanceLevel() : 0; if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting); const visibleRect = visibleWorldRect(world, 180); const terrainLayer = ensureTerrainCache(world, lighting); beginFieldTransform(); drawPlacementPreview(ctx, world); if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH); for (const ef of world.effects) { if (ef.type === "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } for (const it of world.items) { if (isCachedTerrainItem(it)) continue; if (isPinType(it.type) && it.pinState === "lodged") continue; if (it.isStructure && it.type === "plushie" && it.carriedById) continue; if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const it of world.items) { if (it.type === "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } ctx.save(); ctx.lineWidth = 1.15; ctx.setLineDash([4, 4]); ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)"; for (const t of world.tarinai) { if (!isEntityVisibleInRect(t, visibleRect, 80)) continue; const p = t.parentToFollow?.(); if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue; ctx.beginPath(); ctx.moveTo(t.x, t.y); ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y); ctx.stroke(); } ctx.setLineDash([]); ctx.restore(); const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai; for (const t of sorted) { if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); } for (const it of world.items) { if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const it of world.items) { if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const ef of world.effects) { if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } ctx.restore(); // Light affecting the whole visible scene, including characters and ground. drawAtmosphericLighting(ctx, screenW, screenH, lighting); if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); 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; } }; drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); drawNestBoxTooltip(ctx, world); // 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(); if (world.paused) { ctx.save(); ctx.fillStyle = "rgba(255,255,255,0.50)"; ctx.fillRect(0, 0, w, h); ctx.fillStyle = "rgba(42,36,29,0.70)"; ctx.textAlign = "center"; ctx.font = "bold 28px ui-rounded, sans-serif"; ctx.fillText("\u4e00\u6642\u505c\u6b62\u4e2d", w / 2, h / 2); ctx.restore(); } } function drawNestBoxTooltip(ctx, world) { const info = world.pointerNestBoxInfo?.(); if (!info) return; const box = info.box; const title = "\u5de3\u7bb1"; const lines = [title]; ctx.save(); ctx.font = "12px Yomogi, sans-serif"; const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); const lineH = 17; const w = Math.min(240, Math.max(92, textW + 22)); const h = Math.max(38, 16 + lines.length * lineH); const pos = world.worldToScreen ? world.worldToScreen(box.x, box.y) : { x: box.x, y: box.y }; const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8); const y = clamp(pos.y - box.r * 1.8 - h, 8, ctx.canvas.height - h - 8); ctx.fillStyle = "rgba(46,38,30,0.62)"; ctx.strokeStyle = "rgba(255,248,220,0.72)"; 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)}`, 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 = { randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, fillScreenFallback, drawSelectedCard, render, roundedRect };