483 lines
13 KiB
HTML
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>
|