Files
pagerite/pagerite/themes/eyes/banner.html
T

483 lines
13 KiB
HTML

<canvas id="eyes"></canvas>
<style>
#eyes {
width: 100%;
/* 13rem — the banner's layout height, correct from the first frame,
before any external stylesheet has sized #page-banner. Themes that
extend the banner past the layout box (summer's overflow fade) raise
--eyes-h to 100% so the canvas follows the taller stage; the script
still composes the scene against the 13rem box and only extends the
meadow, so the scene itself never shifts. */
height: var(--eyes-h, 13rem);
display: block;
}
</style>
<script><!--
(() => {
const c = document.getElementById('eyes')
const ctx = c.getContext('2d')
// Sync the backing store to the canvas' laid-out size. Checked every
// frame: this inline script runs before the stylesheets that size
// #page-banner, so observers/load events can still miss the transition.
// Assigning width/height also clears the canvas. DPR is read here, not
// captured: it changes with browser zoom.
const syncSize = () => {
const DPR = devicePixelRatio || 1
const w = Math.round(Math.max(1, c.clientWidth) * DPR)
const h = Math.round(Math.max(1, c.clientHeight) * DPR)
if (c.width !== w || c.height !== h) {
c.width = w
c.height = h
}
ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
}
let mx = 0
let my = 0
let lastMove = 0
// Mouse and touch tracked with separate listeners (pointer events arrive
// too late on some mobile browsers). Passive listeners: a drag on the
// banner still scrolls the page — on browsers that stop delivering
// touchmove once scrolling takes over, the gaze just follows until then.
const track = (x, y) => {
const r = c.getBoundingClientRect()
// Convert viewport coordinates into the canvas' CSS-pixel coordinate
// system. This remains correct with browser zoom, CSS transforms, etc.
mx = (x - r.left) * c.clientWidth / r.width
my = (y - r.top) * c.clientHeight / r.height
lastMove = performance.now()
}
addEventListener('mousemove', e => track(e.clientX, e.clientY))
const trackTouch = e => {
const t = e.touches[0]
if (t) track(t.clientX, t.clientY)
}
addEventListener('touchstart', trackTouch, { passive: true })
addEventListener('touchmove', trackTouch, { passive: true })
let gx = 0.5
let gy = 0.5
let tx = 0.5
let ty = 0.5
let yoff = 0
let vy = 0
let hidePhase = 0
let nextMove = 0
let nextHide = 4000 + Math.random() * 5000
let resurfaceAt = 0
const eyes = [
{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 },
{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }
]
const ridgeY = (x, w, h) =>
h * 0.83 +
Math.sin(x * 0.012) * 10 +
Math.sin(x * 0.003 + 1.4) * 16 +
Math.sin(x * 0.02 + 0.7) * 3
const drawCloud = (x, y, s) => {
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.beginPath()
ctx.arc(x - s * 0.55, y + s * 0.05, s * 0.38, 0, 7)
ctx.arc(x - s * 0.12, y - s * 0.08, s * 0.48, 0, 7)
ctx.arc(x + s * 0.32, y, s * 0.42, 0, 7)
ctx.arc(x + s * 0.64, y + s * 0.1, s * 0.28, 0, 7)
ctx.fill()
}
const drawHills = (w, h) => {
ctx.fillStyle = '#b7d7a8'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, h * 0.63)
ctx.quadraticCurveTo(w * 0.18, h * 0.48, w * 0.35, h * 0.62)
ctx.quadraticCurveTo(w * 0.52, h * 0.78, w * 0.68, h * 0.58)
ctx.quadraticCurveTo(w * 0.82, h * 0.43, w, h * 0.57)
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#99c685'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, h * 0.72)
ctx.quadraticCurveTo(w * 0.14, h * 0.6, w * 0.28, h * 0.7)
ctx.quadraticCurveTo(w * 0.46, h * 0.82, w * 0.62, h * 0.66)
ctx.quadraticCurveTo(w * 0.82, h * 0.5, w, h * 0.68)
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
}
const drawBackground = (w, h) => {
const sky = ctx.createLinearGradient(0, 0, 0, h)
sky.addColorStop(0, '#8ed0ff')
sky.addColorStop(0.62, '#d9f1ff')
sky.addColorStop(1, '#eef9ff')
ctx.fillStyle = sky
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = 'rgba(255,240,170,0.5)'
ctx.beginPath()
ctx.arc(w * 0.83, h * 0.2, h * 0.16, 0, 7)
ctx.fill()
drawCloud(w * 0.18, h * 0.2, h * 0.16)
drawCloud(w * 0.43, h * 0.14, h * 0.12)
drawCloud(w * 0.68, h * 0.24, h * 0.15)
drawHills(w, h)
for (let i = 0; i < 5; i++) {
const x = (i + 0.5) * w / 5
const y = h * 0.69 + Math.sin(i * 1.7) * 8
ctx.fillStyle = '#5f8d4e'
ctx.beginPath()
ctx.arc(x, y, 18, Math.PI, 0)
ctx.arc(x - 14, y + 2, 14, Math.PI, 0)
ctx.arc(x + 14, y + 3, 12, Math.PI, 0)
ctx.fill()
}
}
const drawCritter = (cx0, eyeY, R, now, dt) => {
const headR = R * 2
const headCx = cx0
const headCy = eyeY + R * 0.52
ctx.fillStyle = '#5fbe61'
ctx.strokeStyle = '#285838'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(headCx - headR * 0.45, headCy - headR * 0.84)
ctx.quadraticCurveTo(
headCx - headR * 0.62,
headCy - headR * 1.18,
headCx - headR * 0.2,
headCy - headR * 0.94
)
ctx.fill()
ctx.stroke()
ctx.beginPath()
ctx.moveTo(headCx + headR * 0.45, headCy - headR * 0.84)
ctx.quadraticCurveTo(
headCx + headR * 0.62,
headCy - headR * 1.18,
headCx + headR * 0.2,
headCy - headR * 0.94
)
ctx.fill()
ctx.stroke()
ctx.beginPath()
ctx.arc(headCx, headCy, headR, 0, 7)
ctx.fill()
ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,0.12)'
ctx.beginPath()
ctx.arc(
headCx - headR * 0.28,
headCy - headR * 0.22,
headR * 0.4,
0,
7
)
ctx.fill()
ctx.fillStyle = '#4caa50'
for (let i = -1; i <= 1; i++) {
ctx.beginPath()
ctx.arc(
headCx + i * headR * 0.42,
headCy - headR * 0.16,
headR * 0.13,
0,
7
)
ctx.fill()
}
ctx.strokeStyle = '#285838'
ctx.lineCap = 'round'
for (let i = -1; i <= 1; i++) {
ctx.lineWidth = 4
ctx.beginPath()
ctx.moveTo(headCx + i * 10, headCy - headR * 0.94)
ctx.lineTo(
headCx + i * 16,
headCy - headR * 1.1 - Math.sin(now / 180 + i) * 3
)
ctx.stroke()
}
eyes.forEach((e, i) => {
const cx = cx0 + (i ? 1.3 : -1.3) * R
const watching = now - lastMove < 4000
let ptx
let pty
if (watching) {
const dx = mx - cx
const dy = my - eyeY
const d = Math.hypot(dx, dy) || 1
const maxReach = R * 0.43
const responseDistance = R * 4
// Direction points exactly at the cursor, while reach increases
// smoothly with cursor distance.
const reach = maxReach * Math.min(1, d / responseDistance)
ptx = dx / d * reach
pty = dy / d * reach
} else {
ptx = Math.sin(now / 900 + i * 2) * R * 0.3
pty = Math.cos(now / 1300 + i * 3) * R * 0.2
}
e.vx += (ptx - e.x) * 0.08 * dt
e.vy += (pty - e.y) * 0.08 * dt
e.vx *= Math.pow(0.82, dt)
e.vy *= Math.pow(0.82, dt)
e.x += e.vx * dt
e.y += e.vy * dt
const near = Math.hypot(mx - cx, my - eyeY) < R * 2.5
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt
ctx.fillStyle = '#fff'
ctx.strokeStyle = '#1f2d22'
ctx.lineWidth = 2.5
ctx.beginPath()
ctx.ellipse(cx, eyeY, R, R * 1.12, 0, 0, 7)
ctx.fill()
ctx.stroke()
ctx.save()
ctx.beginPath()
ctx.ellipse(cx, eyeY, R, R * 1.12, 0, 0, 7)
ctx.clip()
ctx.fillStyle = '#f2b84b'
ctx.beginPath()
ctx.arc(cx + e.x, eyeY + e.y, R * 0.56, 0, 7)
ctx.fill()
ctx.strokeStyle = 'rgba(140,84,8,0.45)'
ctx.lineWidth = 1
for (let a = 0; a < 12; a++) {
const ang = a / 12 * Math.PI * 2
ctx.beginPath()
ctx.moveTo(cx + e.x, eyeY + e.y)
ctx.lineTo(
cx + e.x + Math.cos(ang) * R * 0.5,
eyeY + e.y + Math.sin(ang) * R * 0.5
)
ctx.stroke()
}
ctx.fillStyle = '#191919'
ctx.beginPath()
ctx.arc(cx + e.x, eyeY + e.y, R * e.pr, 0, 7)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.arc(
cx + e.x - R * 0.14,
eyeY + e.y - R * 0.17,
R * 0.09,
0,
7
)
ctx.fill()
ctx.restore()
ctx.strokeStyle = '#1f2d22'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(cx - R * 0.7, eyeY - R * 1.2)
ctx.quadraticCurveTo(
cx,
eyeY - R * 1.48 - (i ? -1 : 1) * 2,
cx + R * 0.72,
eyeY - R * 1.12
)
ctx.stroke()
})
}
const drawForeground = (w, h) => {
ctx.fillStyle = '#69ae4b'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, ridgeY(0, w, h))
// Sample one step PAST the right edge (x <= w + 8): stopping at w would
// leave the path closing with a visible vertical drop at the edge
// whenever the width isn't a multiple of the 8px step.
for (let x = 0; x <= w + 8; x += 8)
ctx.lineTo(x, ridgeY(x, w, h))
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
ctx.fillStyle = 'rgba(48,102,34,0.18)'
ctx.beginPath()
ctx.moveTo(0, h)
ctx.lineTo(0, ridgeY(0, w, h) + 10)
for (let x = 0; x <= w + 8; x += 8)
ctx.lineTo(x, ridgeY(x, w, h) + 10)
ctx.lineTo(w, h)
ctx.closePath()
ctx.fill()
ctx.strokeStyle = '#4d8d37'
ctx.lineWidth = 2
ctx.lineCap = 'round'
for (let x = 0; x <= w; x += 16) {
const y = ridgeY(x, w, h)
ctx.beginPath()
ctx.moveTo(x, y + 6)
ctx.quadraticCurveTo(x - 4, y - 10, x + 1, y - 2)
ctx.moveTo(x + 1, y + 6)
ctx.quadraticCurveTo(x + 4, y - 12, x + 3, y - 1)
ctx.stroke()
}
for (let i = 0; i < 8; i++) {
const x = (i + 0.4) * w / 8 + Math.sin(i * 2.4) * 10
const y = ridgeY(x, w, h) + 2
ctx.fillStyle = i % 2 ? '#ffdc6b' : '#ff8aa7'
ctx.beginPath()
ctx.arc(x, y, 3, 0, 7)
ctx.arc(x - 4, y + 2, 3, 0, 7)
ctx.arc(x + 4, y + 2, 3, 0, 7)
ctx.fill()
}
}
let prev = performance.now()
const frame = now => {
if (!c.isConnected) return
const dt = Math.min(now - prev, 100) / 16.7
prev = now
syncSize()
const w = c.clientWidth
// Compose the scene against the banner's layout box, not the canvas:
// a theme may extend #page-banner past #banner (e.g. summer overflows
// the artwork into the page for a masked cross-fade), and the critter
// must stay in the visible part. The overflow strip is filled with the
// flat meadow color below — a hard canvas edge would show through the
// fade, a grass extension just blends.
const h = Math.min(c.clientHeight,
c.closest('#banner')?.clientHeight || c.clientHeight)
const R = Math.min(h * 0.11, 38)
if (now > nextMove && !hidePhase) {
tx = 0.15 + Math.random() * 0.7
ty = 0.3 + Math.random() * 0.4
nextMove = now + 2500 + Math.random() * 3500
}
gx += (tx - gx) * 0.02 * dt
gy += (ty - gy) * 0.02 * dt
if (hidePhase === 0 && now > nextHide)
hidePhase = 1
if (hidePhase === 1 && yoff > h * 0.9) {
hidePhase = 2
resurfaceAt = now + 500 + Math.random() * 900
}
if (hidePhase === 2 && now > resurfaceAt) {
hidePhase = 0
nextHide = now + 5000 + Math.random() * 7000
tx = 0.15 + Math.random() * 0.7
ty = 0.3 + Math.random() * 0.4
gx = tx
gy = ty
nextMove = now + 3000 + Math.random() * 3000
}
const yTarget = hidePhase ? h : 0
vy += (yTarget - yoff) * 0.06 * dt
vy *= Math.pow(0.85, dt)
yoff += vy * dt
// Clip the scene to the composed area: the ducking critter travels
// below it, and the overflow strip is only flat meadow painted after —
// without the clip the critter would leave trails there as it sinks.
// Clipping against grass-on-grass is invisible, so the duck still
// reads as sinking into the meadow.
ctx.save()
ctx.beginPath()
ctx.rect(0, 0, w, h)
ctx.clip()
drawBackground(w, h)
const cx0 = gx * w
const ridge = ridgeY(cx0, w, h)
// Normally the full pair of eyes sits above the grass. gy gives it
// a small amount of bobbing/wandering without burying it again.
const eyeY =
ridge -
R * 1.25 +
(gy - 0.5) * R * 0.8 +
yoff
drawCritter(cx0, eyeY, R, now, dt)
drawForeground(w, h)
ctx.restore()
// Extend the meadow into any overflow below the composed scene, with
// the same two layers drawForeground leaves at the bottom (base grass
// plus the dark under-band) so the joint is invisible.
if (c.clientHeight > h) {
ctx.fillStyle = '#69ae4b'
ctx.fillRect(0, h, w, c.clientHeight - h)
ctx.fillStyle = 'rgba(48,102,34,0.18)'
ctx.fillRect(0, h, w, c.clientHeight - h)
}
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
})()
</script>