diff --git a/site/404.html b/site/404.html index e5a68c1c..15e1621a 100644 --- a/site/404.html +++ b/site/404.html @@ -5,15 +5,17 @@ Page not found · StableBar - + - + +
StableBar diff --git a/site/fonts/host-grotesk.woff2 b/site/fonts/host-grotesk.woff2 deleted file mode 100644 index 44d652b5..00000000 Binary files a/site/fonts/host-grotesk.woff2 and /dev/null differ diff --git a/site/fonts/libre-caslon-text-OFL.txt b/site/fonts/libre-caslon-text-OFL.txt new file mode 100644 index 00000000..d718bc9c --- /dev/null +++ b/site/fonts/libre-caslon-text-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Libre Caslon Text Project Authors (https://github.com/thundernixon/Libre-Caslon) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/site/fonts/libre-caslon-text-italic.woff2 b/site/fonts/libre-caslon-text-italic.woff2 new file mode 100644 index 00000000..28c00945 Binary files /dev/null and b/site/fonts/libre-caslon-text-italic.woff2 differ diff --git a/site/fonts/host-grotesk-OFL.txt b/site/fonts/shantell-sans-OFL.txt similarity index 95% rename from site/fonts/host-grotesk-OFL.txt rename to site/fonts/shantell-sans-OFL.txt index 551f7be6..5a9d5251 100644 --- a/site/fonts/host-grotesk-OFL.txt +++ b/site/fonts/shantell-sans-OFL.txt @@ -1,4 +1,4 @@ -Copyright 2023 The Host Grotesk Project Authors (https://github.com/Element-Type/HostGrotesk) HostGrotesk-Italic[wght].ttf: Copyright 2023 The Host Grotesk Project Authors (https://github.com/Element-Type/HostGrotesk) +Copyright 2022 The Shantell Sans Project Authors (https://github.com/arrowtype/shantell-sans) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: @@ -18,7 +18,7 @@ with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, +fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The diff --git a/site/fonts/shantell-sans-display.woff2 b/site/fonts/shantell-sans-display.woff2 new file mode 100644 index 00000000..d04a8608 Binary files /dev/null and b/site/fonts/shantell-sans-display.woff2 differ diff --git a/site/fonts/shantell-sans-hand.woff2 b/site/fonts/shantell-sans-hand.woff2 new file mode 100644 index 00000000..a093ff01 Binary files /dev/null and b/site/fonts/shantell-sans-hand.woff2 differ diff --git a/site/index.html b/site/index.html index 9be8a6e0..90acf53b 100644 --- a/site/index.html +++ b/site/index.html @@ -45,17 +45,32 @@ "url": "https://stablebar.app/" } - + - + + + + + + + + @@ -68,8 +83,10 @@ - + + + @@ -106,365 +123,277 @@ + + + + + + + -
-
- StableBar - -
+ + + -
-
-

A native, configurable
menu bar for macOS.

-

Themes, widgets and popups, set up in the app or in plain JSON.

-
- Download for Mac v0.9.0 -

macOS 14 or later on Apple silicon.
Free and open source, MIT License.

+
+ -
-
- -
- - -
- - -
-
Music
-
Midnight CityM83Hurry Up, We’re Dreaming
-
-
1:274:03
-
- - - - - -
- -
-
-

Today

-
09:15Standup
-
10:30Design review
-
12:30Lunch with Ana
-
15:001:1 with Sam
-
16:30Release planning
-
-
Calendar
-
-
-

System

- -
CPU18 %
-
-
Memory54 %
-
-
-
Activity Monitor
-
-
- -
- Right-click the bar, or - - Drag to move · − removes · + adds - - -
- - -

Drag it, or use the Left and Right arrow keys, to move it. Delete removes it.

-

+
+ + +
+
Music
+
Midnight CityM83Hurry Up, We’re Dreaming
+
+
1:274:03
+
+ + + + +
+ +
+
+

Today

+
09:15Standup
+
10:30Design review
+
12:30Lunch with Ana
+
15:001:1 with Sam
+
16:30Release planning
+
+
Calendar
+
+
+

System

+ +
CPU18 %
+
+
Memory54 %
+
+
+
Activity Monitor
+
+
-
-
-
ThemeSave as Theme…
-
-
+
+ Drag to move · − removes · + adds + + +
+ + + +

Drag it, or use the Left and Right arrow keys, to move it. Delete removes it.

+

+
+ +
+
+
+

A native, configurable menu bar for macOS.

+

Themes, widgets and popups, set up in the app or in plain JSON.

+
+ Download for Mac v0.9.0 +

macOS 14 or later on Apple silicon.
Free and open source, MIT License.

-
-
-
zsh
-
Click a line to run it on the bar above.
-
+
+

This is not a menu bar.* *It is.

+

User guideGitHub

-
-
-

Configure all of it.

-
-

Edit the bar where it is. Drag widgets between positions, take one off with −, and add one with + right where it should go. Changes save as you go, with Undo.

- -

Every widget, style and popup is also a setting, per display and per kind of screen, in Settings or in plain JSON. StableBar applies the file when you save it, and keeps the last working bar if a change is invalid.

-

Tiling, natively. Workspaces come straight from AeroSpace, yabai or OmniWM, with app icons or one item per window, and update in place instead of recreating windows.

- Workspaces in the guide -
-
-
-

Window managers

-
    -
  • AeroSpace and OmniWMWorkspace, monitor, window and focus events, live. Reconnects when the window manager restarts.
  • -
  • yabaiSpaces, windows and displays, refreshed by yabai signals.
  • -
  • One item per windowWindow titles in the workspace; click one to focus it.
  • -
  • No tiling managermacOS Spaces, and windows kept clear of the bar.
  • -
-
-
-

Configuration

-
    -
  • On the bar itselfDrag, add and remove widgets where they sit, with Undo.
  • -
  • JSON with a schemaCompletion and validation in your editor.
  • -
  • Every styleFonts, colors, padding, corners and borders, per widget or per theme.
  • -
  • Per kind of screenIts own layout, size and theme on the laptop or a monitor.
  • -
  • ProfilesA different bar on battery, in dark mode or for one app.
  • -
+
+

The light follows your pointer.Drag sideways to steer the light.

+
+ + + +
+

Or click the hill or the sea.Or tap the hill or the sea.

-
-

Popups

-
-

Any widget can open a popup. Build it from rows, columns, text, graphs, lists, buttons, sliders, toggles and search fields, filled with the widget's data.

- Custom popups in the guide -
-
-
-
-

System

- -
CPU18 %
-
-
Memory54 %
-
-
-
Activity Monitor
-
-
-

Today

-
09:15Standup
-
10:30Design review
-
12:30Lunch with Ana
-
15:001:1 with Sam
-
16:30Release planning
-
-
Calendar
-
-
-

Controls

- - -
Mode
-
- -
Finder
Calendar
Activity Monitor
Terminal
-
-
+
+
+ +

Edit the bar where it is.

+

Drag widgets between positions, take one off with −, and add one with + right where it should go. Changes save as you go, with Undo.

+ + +

Every widget, style and popup is also a setting, per display and per kind of screen, in Settings or in plain JSON. StableBar applies the file when you save it, and keeps the last working bar if a change is invalid.

+

The keeper rearranges things when bored.

-
-

Themes

-
-

Choose a theme during setup, cycle through them on your own bar with Try Themes…, or get more from the store. Save as Theme… turns your own look into one.

+
+
+

Themes

+

Choose a theme during setup, cycle through them on your own bar with Try Themes…, or get more from the Store. Save as Theme… turns your own look into one.

+
+ + + + + + + +

Pick one here and the bar at the top changes with it.

- Themes in the guide -
-
- + +

Same lighthouse, different pyjamas.

-
-

Any script is a widget

-
+
+
+

Any script is a widget.

A widget can run any command that prints text or a JSON object. Refresh it on a timer or on events, and give it a popup with graphs, lists and controls.

-

The command line queries and edits the running bar, and other tools can send it events.

- Create a widget -
-
-
-
widgets/disk.shsh
-
#!/bin/sh
+      
+
+
widgets/disk.shsh
+
#!/bin/sh
 used=$(df -h / | awk 'NR == 2 { print $5 }')
 printf '{"text":"%s","icon":"internaldrive"}' "$used"
+
+
61%
82%
09:41
-
-
config.jsonjson
-
{
-  "id": "disk", "type": "script",
-  "position": "right", "interval": 300,
-  "command": "~/.config/stablebar/widgets/disk.sh"
-}
-
-
61%
82%
09:41
-
-
+ -
-

The Store

-
-

Add widgets other people wrote, from Settings or the bar’s +, without writing a script. Before anything runs, StableBar shows what a package does: the commands it runs, the hosts it contacts and the macOS permissions it uses.

-

Checked on the way in. Every Store package is open source, and the Store builds and publishes it, not its author. A maintainer reads each version that runs commands, and StableBar checks every download against its SHA-256. Tokens stay in your Keychain, and versions stay pinned until you choose another.

-

Your team’s own widgets. Host a private source for packages that can’t be public, and invite your team with a join link. A stablebar:// link opens one package’s page in StableBar.

- Packages and the store in the guide -
-
-
-
- -
-
Midnight City · M83
Now playing

Shows the playing or paused track from Music or Spotify with its artwork and bouncing bars while it plays.

Widget · by Seb-GRAFInstalled
-
42%  18%
AI usage

Shows your Claude Code and Codex limits in one widget, with reset countdowns and weekly pace.

Widget · by Seb-GRAF
-
2
Dock badges

Shows the total unread count from running apps’ Dock badges, with a popup that opens each app.

Widget · by Seb-GRAF
-
3
GitHub notifications

Shows your unread GitHub notification count and lists the latest ones, each opening on GitHub.

Widget · by Seb-GRAF
-
4
Homebrew outdated

Shows how many Homebrew formulae and casks are outdated, and lists them.

Widget · by Seb-GRAF
-
14°
Weather

Shows the current temperature and conditions from wttr.in, with feels-like, humidity, wind and today’s high and low.

Widget · by Seb-GRAF
+
+

Or get one from the Store.

+

Add widgets other people wrote, from Settings or the bar’s +, without writing a script. Before anything runs, StableBar shows what a package does: the commands it runs, the hosts it contacts and the macOS permissions it uses.

+
+
+
+
+
Midnight City · M83
Now playing

Shows the playing or paused track from Music or Spotify with its artwork and bouncing bars while it plays.

Widget · by Seb-GRAFInstalled
+
42%  18%
AI usage

Shows your Claude Code and Codex limits in one widget, with reset countdowns and weekly pace.

Widget · by Seb-GRAF
+
2
Dock badges

Shows the total unread count from running apps’ Dock badges, with a popup that opens each app.

Widget · by Seb-GRAF
+
3
GitHub notifications

Shows your unread GitHub notification count and lists the latest ones, each opening on GitHub.

Widget · by Seb-GRAF
+
4
Homebrew outdated

Shows how many Homebrew formulae and casks are outdated, and lists them.

Widget · by Seb-GRAF
+
14°
Weather

Shows the current temperature and conditions from wttr.in, with feels-like, humidity, wind and today’s high and low.

Widget · by Seb-GRAF
+
+
+
+
- +

This one was written by a sheep. Allegedly.

-
-

Every display

-
-

Placement that knows about the notch, widgets per kind of screen, and a separate bar for each screen setup. Each kind of screen can have its own theme.

- Displays and layout -
-
-
- - -
Monitor. Léman, with more room for widgets.
-
-
- -
+
+
+

Every window gets its own little room.

+ +
-
-
-

See it on your own bar.

-

Setup walks you through a preset, a theme and a placement, and shows each choice on your real bar. Nothing is saved until you click Finish setup.

+
+
+

See it on your own bar.

+
    +
  1. Download StableBar.
  2. +
  3. Open it from Applications.
  4. +
  5. Setup walks you through a preset, a theme and a placement, and shows each choice on your real bar.
  6. +
  7. Nothing is saved until you click Finish setup.
  8. +
- Download for Mac v0.9.0 + Download for Mac v0.9.0

macOS 14 or later on Apple silicon.
Releases update themselves.

-

Or with Homebrew: brew install --cask seb-graf/tap/stablebar

-
-
-
-
-
StableBar
-
-
StableBar
-
Drag to download
-
Applications
-
-

Then open StableBar from Applications.

-
+

Or with Homebrew: brew install --cask seb-graf/tap/stablebar

+

The lamp is on. Come in.

-
- - -
+
+ diff --git a/site/main.js b/site/main.js index 36b85a69..3f6b266e 100644 --- a/site/main.js +++ b/site/main.js @@ -1,3 +1,8 @@ +/* stablebar.app: the bar demos, the paper notes, and the drawing behind them (scene.js). + Everything here works without the drawing; the drawing only adds to it. */ +(() => { +"use strict"; +const root = document.documentElement; const THEMES = [ ["built-in", "Built-in", "ws-fill"], ["catppuccin", "Catppuccin", "ws-fill"], @@ -7,7 +12,6 @@ const THEMES = [ ["notch", "Notch", "ws-text"], ["phosphor", "Phosphor", "ws-fill"], ]; -const desk = document.getElementById("desk"); const dock = document.getElementById("dock"); const bar = document.getElementById("bar"); const gl = document.getElementById("gl"); @@ -17,11 +21,25 @@ const more = bar.querySelector(".more"); const pop = document.getElementById("pop"); const panes = [...pop.querySelectorAll(".pane")]; const menu = pop.querySelector('[data-id="more"]'); +const heroNote = document.querySelector(".hero-note"); const reduce = matchMedia("(prefers-reduced-motion: reduce)"); const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); let openId = null; let editing = false; +/* The drawing, if scene.js is there. Every call goes through S, so a scene that fails never takes the page with it. */ +let scene = null; +let sceneFailed = false; +function S(method, ...args) { + if (!scene || typeof scene[method] !== "function") return undefined; + try { return scene[method](...args); } catch (err) { + if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } + return undefined; + } +} +/* The bar's height when it isn't being edited; Nord is shorter */ +const restHeight = () => parseFloat(getComputedStyle(dock).getPropertyValue("--rest-h")) || 40; + /* Now playing: the track moves on while it plays */ const track = { at: 87, length: 243, playing: true }; const trackAt = document.getElementById("np-at"); @@ -49,28 +67,24 @@ function tick() { tick(); setInterval(tick, 1000); -/* Sample bars: three workspaces, Finder, battery and clock, as in Settings' theme cards */ -function mini() { - return `
-
123Finder
-
82%9:41
`; -} -const cards = document.getElementById("cards"); -const store = document.querySelector("#board .store"); -for (const [id, name, ws] of THEMES) { - const card = ``; - cards.insertAdjacentHTML("beforeend", card); - store.insertAdjacentHTML("beforebegin", card); -} - +/* Themes: the tags on the washing line restyle the bar, and the drawing: each theme draws the scene in its own style + (styles/), which the lighthouse's light brings in as it turns once round, the lighthouse in the theme's pyjamas */ function setTheme(id) { - desk.dataset.theme = id; - bar.className = `bar ${THEMES.find(t => t[0] === id)[2]}`; + barTheme(id); for (const b of document.querySelectorAll("[data-theme-pick]")) { b.classList.toggle("on", b.dataset.themePick === id); b.setAttribute("aria-pressed", b.dataset.themePick === id); } - requestAnimationFrame(fit); + S("setTheme", id); + requestAnimationFrame(() => { + fit(); + S("setTopInset", restHeight()); + }); +} +// the bar alone; out at sea it takes the theme of the island ahead (see onBar) +function barTheme(id) { + dock.dataset.theme = id; + bar.className = `bar ${THEMES.find(t => t[0] === id)[2]}`; } for (const b of document.querySelectorAll("[data-theme-pick]")) b.addEventListener("click", () => setTheme(b.dataset.themePick)); @@ -80,7 +94,7 @@ function fit() { const widgets = [...bar.querySelectorAll("[data-pri]")].filter(w => w.dataset.pri !== "never"); widgets.forEach(w => w.classList.remove("over")); more.hidden = true; - if (editing) { fades(); return; } + if (editing) { fades(); sendBar(); return; } const order = widgets.map((w, i) => ({ w, p: Number(w.dataset.pri), i })).sort((a, b) => a.p - b.p || b.i - a.i).map(o => o.w); const shown = order.filter(w => w.offsetWidth > 0); for (const w of shown) { @@ -99,8 +113,32 @@ function fit() { } place(); slide(); + sendBar(); } +/* The bar's visible widgets, for the lighthouse's ball game (scene.setBar): each workspace number is + its own target, and the ball bounces on the line under them: the widgets' bottom edge, the bar's + when it has a strip, or the strip's pen line once the page has scrolled. Measured when the bar + changes (fit, the header swap, resizing, themes, scrolling past the top), never every frame. */ +function barTargets() { + if (editing) return []; + // from the theme's tokens rather than the bar's box, which may still be easing into a new theme + const cs = getComputedStyle(bar), strip = cs.getPropertyValue("--strip-bg").trim() !== "transparent"; + const line = dock.classList.contains("scrolled") ? restHeight() + 3.5 : strip ? restHeight() - (parseFloat(cs.getPropertyValue("--strip-m")) || 0) : null; + const out = []; + const add = (key, el) => { + const r = el.getBoundingClientRect(); + if (r.width > 3 && r.right > 0 && r.left < innerWidth) out.push({ key, x0: r.left, x1: r.right, y: line ?? r.bottom }); + }; + for (const w of bar.querySelectorAll(":scope > .g > .w[data-key]")) { + if (w.hidden || w.classList.contains("over") || getComputedStyle(w).visibility === "hidden") continue; + if (w.dataset.key === "ws") for (const b of w.querySelectorAll(".wsi")) add(`ws:${b.dataset.ws}`, b); + else add(w.dataset.key, w); + } + return out; +} +function sendBar() { if (scene) S("setBar", barTargets()); } + /* Popups open below their widget, or from More widgets when the widget is hidden */ function anchorFor(id) { if (id === "more") return more; @@ -119,9 +157,12 @@ function place() { pop.style.width = `${pane.offsetWidth}px`; pop.style.height = `${pane.offsetHeight}px`; } +/* The control that opens a widget's popup: the widget itself, or the app name in StableBar's menus */ +const popButton = w => w.matches("button") ? w : w.querySelector(":scope > button"); /* Opening another widget's popup morphs the open one into it, as the app does */ function open(id) { if (id !== openId && anchorFor(id).hidden) return; + hideTour(); const from = openId; openId = openId === id ? null : id; pop.classList.toggle("morph", Boolean(from && openId)); @@ -129,7 +170,7 @@ function open(id) { for (const p of panes) p.classList.toggle("on", p.dataset.id === openId); for (const w of bar.querySelectorAll("[data-pop]")) { w.classList.toggle("open", w.dataset.pop === openId || Boolean(openId && anchorFor(openId) === w)); - if (w.matches("button")) w.setAttribute("aria-expanded", w.classList.contains("open")); + popButton(w)?.setAttribute("aria-expanded", w.classList.contains("open")); } place(); } @@ -139,29 +180,33 @@ for (const w of bar.querySelectorAll("[data-pop]")) w.addEventListener("click", open(w.dataset.pop); }); for (const b of menu.querySelectorAll("[data-open]")) b.addEventListener("click", e => { e.stopPropagation(); open(b.dataset.open); }); -document.addEventListener("click", e => { if (openId && !e.target.closest(".pop, .win, .card, .w")) open(openId); }); +document.addEventListener("click", e => { if (openId && !e.target.closest(".pop, .win, .tag, .w")) open(openId); }); /* Escape closes the popup and returns focus to its widget */ document.addEventListener("keydown", e => { if (e.key !== "Escape" || !openId) return; const from = pop.contains(document.activeElement) && anchorFor(openId); open(openId); - if (from) from.focus(); + if (from) (popButton(from) || from).focus(); }); -/* Workspaces: the focus highlight slides to the one you click, as on the real bar */ +/* Workspaces: the focus highlight slides to the one you click, as on the real bar. + The postcard's large widget is the same four workspaces, and the drawing's beach huts follow. */ const ind = bar.querySelector(".ind"); function slide() { const on = bar.querySelector(".wsi.on"); ind.style.setProperty("--x", `${on.offsetLeft}px`); ind.style.setProperty("--w", `${on.offsetWidth}px`); } -for (const el of bar.querySelectorAll(".wsi")) el.addEventListener("click", () => { - bar.querySelectorAll(".wsi").forEach(e => { - e.classList.toggle("on", e === el); - e.setAttribute("aria-pressed", e === el); - }); +function focusWorkspace(n) { + for (const e of document.querySelectorAll(".wsi[data-ws]")) { + const on = e.dataset.ws === String(n); + e.classList.toggle("on", on); + e.setAttribute("aria-pressed", on); + } slide(); -}); + S("setWorkspace", n); +} +for (const el of document.querySelectorAll(".wsi[data-ws]")) el.addEventListener("click", () => focusWorkspace(Number(el.dataset.ws))); /* The app's menus open on hover when the bar has room for them */ const finder = bar.querySelector(".finder"); @@ -186,183 +231,50 @@ volume.addEventListener("input", () => { sound.querySelector(".pct").textContent = `${volume.value}%`; volume.style.setProperty("--v", `${volume.value}%`); }); +/* Those two change the widgets' widths, and a new theme the bar's height: the ball game measures the bar again once they settle */ +bar.addEventListener("transitionend", e => { if ((e.propertyName === "grid-template-columns" || e.target === bar && e.propertyName === "height") && !editing) sendBar(); }); -/* Controls */ -for (const seg of document.querySelectorAll(".seg")) for (const b of seg.querySelectorAll("button")) b.addEventListener("click", () => { - seg.querySelectorAll("button").forEach(x => { - x.classList.toggle("on", x === b); - x.setAttribute("aria-pressed", x === b); - }); -}); -for (const s of document.querySelectorAll(".search")) s.addEventListener("input", () => { - const q = s.value.toLowerCase(); - s.nextElementSibling.querySelectorAll("div").forEach(d => d.hidden = !d.textContent.toLowerCase().includes(q)); - place(); -}); - -/* The terminal runs its commands against the bar */ -const out = document.getElementById("out"); -for (const b of document.querySelectorAll("[data-run]")) b.addEventListener("click", e => { - e.stopPropagation(); - endEditing(); - if (b.dataset.run === "popup") { - open("agenda"); - out.textContent = `{"popup":"agenda","open":${openId === "agenda"}}`; - } else { - bar.querySelector(".clock").dataset.format = "seconds"; - menu.querySelector(".clock").dataset.format = "seconds"; - tick(); - fit(); - out.textContent = '{"set":"clock"}'; - } - b.classList.add("ran"); -}); - -/* The bar stays at the top of the window once the desk reaches it, - and becomes the page's header once the desk has scrolled away. */ -function dockState() { - const r = desk.getBoundingClientRect(); - const header = r.bottom <= 120; - const docked = r.top <= 0; - dock.classList.toggle("docked", docked); - dock.style.left = docked ? `${r.left}px` : ""; - dock.style.width = docked ? `${r.width}px` : ""; - if (header === dock.classList.contains("header")) return; - if (header) endEditing(); +/* Once the page scrolls, a strip of sky slides in behind the bar. Once the headline has gone + under it, the bar becomes the page's header. While the bar is being edited it stays the demo. */ +let swapAt = 0; +function wantHeader() { + const line = restHeight() + 56, bottom = heroNote.getBoundingClientRect().bottom; + // once it's the header it stays so until the headline is well back in view, so small scrolls don't flip it + return !editing && bottom < (dock.classList.contains("header") ? line + 120 : line); +} +function setHeader(header) { + hideTour(); dock.classList.add("measure"); dock.classList.toggle("header", header); fit(); - dock.classList.toggle("header", !header); dock.offsetWidth; dock.classList.remove("measure"); - dock.classList.toggle("header", header); if (openId) open(openId); - setTimeout(fit, 450); } - -let queued = false; -addEventListener("scroll", () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; dockState(); }); -}, { passive: true }); - -/* Drag a window by its title bar. It leans into the motion and glides when let go. */ -const layers = [...desk.querySelectorAll(".layer")]; -const px = (el, name) => parseFloat(el.style.getPropertyValue(name)) || 0; -function bounds(layer) { - return { - x: [8 - layer.offsetLeft, desk.clientWidth - layer.offsetWidth - 8 - layer.offsetLeft], - y: [bar.offsetHeight + 8 - layer.offsetTop, desk.clientHeight - 48 - layer.offsetTop], - }; -} -function moveTo(layer, dx, dy) { - const b = bounds(layer); - const x = clamp(dx, ...b.x), y = clamp(dy, ...b.y); - layer.style.setProperty("--dx", `${x}px`); - layer.style.setProperty("--dy", `${y}px`); - return [x !== dx, y !== dy]; -} -for (const layer of layers) { - const handle = layer.querySelector(".tb"); - let drag = null, frame = 0, vx = 0, vy = 0; - - function animate() { - const tilt = px(layer, "--tilt"); - if (!drag) { - const [hitX, hitY] = moveTo(layer, px(layer, "--dx") + vx * 16, px(layer, "--dy") + vy * 16); - vx = hitX ? 0 : vx * .92; - vy = hitY ? 0 : vy * .92; - } else { - vx *= .85; - } - const target = drag ? clamp(vx * 6, -8, 8) : 0; - layer.style.setProperty("--tilt", `${tilt + (target - tilt) * .2}deg`); - if (drag || Math.abs(vx) + Math.abs(vy) > .01 || Math.abs(tilt) > .05) frame = requestAnimationFrame(animate); - else layer.style.setProperty("--tilt", "0deg"); +/* Scrolling swaps the bar's contents in one step behind a short fade, as macOS swaps an app's menus, + instead of letting widgets slide over one another. Editing, resizing and loading swap at once. */ +function dockState(fade = false) { + if (dock.classList.contains("scrolled") !== scrollY > 2) { + dock.classList.toggle("scrolled", scrollY > 2); + sendBar(); } - - handle.addEventListener("pointerdown", e => { - if (e.button !== 0) return; - e.preventDefault(); - handle.setPointerCapture(e.pointerId); - layers.forEach(l => l.style.zIndex = l === layer ? 5 : 4); - const r = layer.getBoundingClientRect(); - layer.style.transformOrigin = `${e.clientX - r.left}px ${e.clientY - r.top}px`; - drag = { x: e.clientX, y: e.clientY, dx: px(layer, "--dx"), dy: px(layer, "--dy"), lx: e.clientX, ly: e.clientY, t: e.timeStamp }; - vx = vy = 0; - layer.classList.add("dragging"); - cancelAnimationFrame(frame); - if (!reduce.matches) frame = requestAnimationFrame(animate); - }); - handle.addEventListener("pointermove", e => { - if (!drag) return; - moveTo(layer, drag.dx + e.clientX - drag.x, drag.dy + e.clientY - drag.y); - const dt = Math.max(1, e.timeStamp - drag.t); - vx = vx * .5 + (e.clientX - drag.lx) / dt * .5; - vy = vy * .5 + (e.clientY - drag.ly) / dt * .5; - Object.assign(drag, { lx: e.clientX, ly: e.clientY, t: e.timeStamp }); - }); - const release = () => { - if (!drag) return; - drag = null; - layer.classList.remove("dragging"); + if (swapAt && fade) return; + swapAt = 0; + bar.classList.remove("swap"); + const header = wantHeader(); + if (header === dock.classList.contains("header")) return; + if (!fade || reduce.matches) return setHeader(header); + // fade out, then swap on the first frame after the fade (frames, not a timer, so a busy page can't hold it) + bar.classList.add("swap"); + const at = swapAt = performance.now() + 130; + const step = now => { + if (swapAt !== at) return; + if (now < at) return requestAnimationFrame(step); + swapAt = 0; + setHeader(wantHeader()); + bar.classList.remove("swap"); }; - handle.addEventListener("pointerup", release); - handle.addEventListener("pointercancel", release); -} - -/* The disk image: drag StableBar onto Applications to download the DMG the buttons link */ -const ico = document.getElementById("app-ico"); -const folder = document.getElementById("folder"); -const dmg = document.querySelector(".install .cta").href; -let grab = null; -const overFolder = e => { - const r = folder.getBoundingClientRect(); - return e.clientX > r.left - 20 && e.clientX < r.right + 20 && e.clientY > r.top - 20 && e.clientY < r.bottom + 20; -}; -ico.addEventListener("pointerdown", e => { - if (ico.classList.contains("in")) return; - e.preventDefault(); - ico.setPointerCapture(e.pointerId); - grab = { x: e.clientX, y: e.clientY }; - ico.classList.add("dragging"); -}); -ico.addEventListener("pointermove", e => { - if (!grab) return; - ico.style.transform = `translate(${e.clientX - grab.x}px, ${e.clientY - grab.y}px) scale(1.06)`; - folder.classList.toggle("over", overFolder(e)); -}); -ico.addEventListener("pointerup", e => { - if (!grab) return; - grab = null; - ico.classList.remove("dragging"); - folder.classList.remove("over"); - if (!overFolder(e)) { ico.style.transform = ""; return; } - // Started from the drop itself, so the browser counts it as the visitor's own download. - location.href = dmg; - const f = folder.getBoundingClientRect(); - const home = ico.parentElement.getBoundingClientRect(); - ico.style.transform = `translate(${f.left + f.width / 2 - (home.left + home.width / 2)}px, ${f.top + f.height / 2 - (home.top + home.height / 2)}px) scale(.4)`; - ico.classList.add("in"); - setTimeout(() => { - ico.style.transition = "none"; - ico.style.transform = ""; - requestAnimationFrame(() => requestAnimationFrame(() => { - ico.style.transition = ""; - ico.classList.remove("in"); - })); - }, 1600); -}); -ico.addEventListener("pointercancel", () => { grab = null; ico.classList.remove("dragging"); ico.style.transform = ""; }); - -/* The product pictures ease in as they scroll into view */ -const io = new IntersectionObserver(entries => { - for (const e of entries) if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } -}, { rootMargin: "0px 0px -10% 0px" }); -for (const el of document.querySelectorAll(".pops, .board, .scripts, .store-win, .setup, .install .desktop")) { - el.classList.add("reveal"); - io.observe(el); + requestAnimationFrame(step); } /* Edit Bar in Place: the bar becomes its own editor, as in the app. Each position gets a tray, @@ -373,10 +285,11 @@ const NAMES = { apple: "Apple menu", ws: "Workspaces", app: "App menus", np: "No const ease = "cubic-bezier(.22, 1, .36, 1)"; const editCtl = document.getElementById("edit-ctl"); const undoButton = document.getElementById("edit-undo"); +const tryEdit = document.getElementById("try-edit"); const addPop = document.getElementById("addpop"); const ctx = document.getElementById("ctx"); const live = document.getElementById("edit-live"); -let past = [], future = [], drag = null, adding = null, made = 0; +let past = [], future = [], drag = null, adding = null, made = 0, opener = null; const lane = i => trays[i].querySelector(".lane"); const tilesIn = (i, except) => [...lane(i).querySelectorAll(":scope > .tile:not([hidden])")].filter(t => t !== except); @@ -405,11 +318,14 @@ function tile(w) { return t; } -function beginEditing() { - if (editing || dock.classList.contains("header")) return; +function beginEditing(from) { + if (editing) return; if (openId) open(openId); closeMenu(); + opener = from || (bar.contains(document.activeElement) ? document.activeElement : null); + hideTour(); editing = true; + dockState(); past = []; future = []; sound.classList.remove("expand"); @@ -417,7 +333,7 @@ function beginEditing() { for (const [i, g] of trays.entries()) { const l = document.createElement("div"); l.className = "lane"; - for (const w of [...g.children].filter(w => w.dataset.key && !w.matches(".more, .dl, .site"))) l.append(tile(w)); + for (const w of [...g.children].filter(w => w.dataset.key && !w.matches(".more, .dl, .site, .moon"))) l.append(tile(w)); const add = document.createElement("button"); add.className = "add"; add.title = add.ariaLabel = `Add a widget to ${TRAYS[i]}`; @@ -469,9 +385,16 @@ function endEditing() { editing = false; dock.classList.remove("editing", "dragging"); updateControls(); - requestAnimationFrame(() => { fit(); slide(); }); + requestAnimationFrame(() => { fit(); slide(); dockState(); }); say("Finished editing the bar."); } +/* Done and Escape hand focus back to whatever started editing */ +function finishEditing() { + const back = opener; + opener = null; + endEditing(); + if (back?.isConnected && !back.closest("[inert]")) back.focus({ preventScroll: true }); +} function updateControls() { undoButton.disabled = !past.length; @@ -588,7 +511,7 @@ function lift(e) { const copy = t.firstElementChild.cloneNode(true); copy.querySelector(".ind")?.remove(); ghost.append(copy); - desk.append(ghost); + dock.append(ghost); drag.ghost = ghost; t.classList.add("lifted"); dock.classList.add("dragging"); @@ -783,7 +706,7 @@ function add(c) { record(before, `Added ${c.name} to ${TRAYS[i]}.`); } -/* The Store section: Get reviews a package, and Add to bar puts it on the bar at the top of the page */ +/* The Store: Get reviews a package, and Add to bar puts it on the bar at the top of the page */ const pkgReview = document.getElementById("pkg-review"); const storeWin = document.querySelector(".store-win"); let reviewing = null; @@ -797,6 +720,7 @@ function closeReview() { reviewing = null; pkgReview.hidden = true; storeWin.classList.remove("sheet"); + storeWin.inert = false; if (pkgReview.contains(document.activeElement) && get.isConnected) get.focus({ preventScroll: true }); } document.querySelector(".pkgs").addEventListener("click", e => { @@ -809,6 +733,8 @@ document.querySelector(".pkgs").addEventListener("click", e => { pkgReview.innerHTML = reviewHTML(c, "Cancel", "Add to bar"); pkgReview.hidden = false; storeWin.classList.add("sheet"); + // the sheet covers the window, as on the Mac: what's under it can't take focus until it closes + storeWin.inert = true; pkgReview.querySelector(".back").addEventListener("click", closeReview); pkgReview.querySelector(".go").addEventListener("click", () => { closeReview(); @@ -845,41 +771,29 @@ function showMenu(x, y, items) { } function closeMenu() { ctx.hidden = true; } bar.addEventListener("contextmenu", e => { - if (dock.classList.contains("header") || innerWidth < 720 || e.target.closest(".menus a")) return; + if (innerWidth < 720 || e.target.closest(".menus a")) return; e.preventDefault(); const t = editing && e.target.closest(".tile"); - if (!editing) showMenu(e.clientX, e.clientY, [["Edit Bar in Place", beginEditing]]); - else if (t) showMenu(e.clientX, e.clientY, [["Move Left", () => step(t, -1)], ["Move Right", () => step(t, 1)], ["Remove Widget", () => remove(t)], null, ["Finish Editing Bar", endEditing]]); - else showMenu(e.clientX, e.clientY, [["Finish Editing Bar", endEditing]]); + if (!editing) showMenu(e.clientX, e.clientY, [["Edit Bar in Place", () => beginEditing(opener)]]); + else if (t) showMenu(e.clientX, e.clientY, [["Move Left", () => step(t, -1)], ["Move Right", () => step(t, 1)], ["Remove Widget", () => remove(t)], null, ["Finish Editing Bar", finishEditing]]); + else showMenu(e.clientX, e.clientY, [["Finish Editing Bar", finishEditing]]); }); +bar.addEventListener("contextmenu", () => { if (!editing && bar.contains(document.activeElement)) opener = document.activeElement; }, true); ctx.addEventListener("keydown", e => { const items = [...ctx.querySelectorAll("button")], k = items.indexOf(document.activeElement); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); items[(k + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus(); } }); -document.getElementById("edit-start").addEventListener("click", e => { +/* From the Edit section: the bar above turns into its own editor, right where you are */ +tryEdit.addEventListener("click", e => { e.stopPropagation(); - beginEditing(); + beginEditing(tryEdit); bar.querySelector(".tile")?.focus({ preventScroll: true }); }); -/* From the Configure section: back to the bar, then edit it */ -document.getElementById("try-edit").addEventListener("click", e => { - e.stopPropagation(); - scrollTo({ top: 0, behavior: reduce.matches ? "auto" : "smooth" }); - const until = performance.now() + 2500; - const go = () => { - if (scrollY > 1) { if (performance.now() < until) requestAnimationFrame(go); return; } - dockState(); - beginEditing(); - bar.querySelector(".tile")?.focus({ preventScroll: true }); - }; - requestAnimationFrame(go); -}); undoButton.addEventListener("click", e => { e.stopPropagation(); undo(); }); document.getElementById("edit-done").addEventListener("click", e => { e.stopPropagation(); - endEditing(); - document.getElementById("edit-start").focus({ preventScroll: true }); + finishEditing(); }); document.addEventListener("click", e => { if (!ctx.hidden && !e.target.closest(".ctx")) closeMenu(); @@ -892,7 +806,7 @@ document.addEventListener("keydown", e => { if (!ctx.hidden) { closeMenu(); return; } if (adding) { closeAdd(); return; } if (drag?.started) { settle(false); drag = null; return; } - if (editing) { endEditing(); document.getElementById("edit-start").focus({ preventScroll: true }); } + if (editing) finishEditing(); } else if (editing && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z" && !e.target.closest("input")) { e.preventDefault(); e.shiftKey ? redo() : undo(); @@ -900,29 +814,582 @@ document.addEventListener("keydown", e => { }); addEventListener("scroll", closeMenu, { passive: true }); -/* Light and dark: starts from the system setting, remembers the visitor's choice */ +/* ---------- The drawing ---------- */ + +/* Scrolling brings the evening: each section holds its own hour, and the hours blend in between. + The night switch brings the night at once and keeps it. */ +const sections = [...document.querySelectorAll("main > section[data-state]")]; +let marks = []; +function measureMarks() { + marks = sections.map(s => { + const r = s.getBoundingClientRect(), top = r.top + scrollY, h = r.height, pad = Math.min(h * .25, innerHeight * .4); + return { name: s.dataset.state, d: Number(s.dataset.dusk), top, bottom: top + h, a: top + pad, b: top + h - pad }; + }); +} +const smooth = t => t * t * (3 - 2 * t); +// past the last note, the dive (see below), in laps of one island each: there is room for twelve +const dive = document.getElementById("dive"), lap = () => dive.offsetHeight / 12, laps = () => (scrollY - dive.offsetTop) / lap(); +function hour() { + // out at sea: from the night the page ends on, round to day and back to night once a round of islands + if (!dive.hidden && laps() > 0) return { d: (1 + Math.cos(2 * Math.PI * laps() / window.Hill.islands)) / 2, name: "hero" }; + const y = scrollY + innerHeight / 2; + let d = marks[0].d, name = marks[0].name; + for (let i = 0; i < marks.length; i++) { + const m = marks[i], n = marks[i + 1]; + if (y >= m.top) name = m.name; + if (y >= m.a) d = m.d; + if (n && y > m.b && y < n.a) d = m.d + (n.d - m.d) * smooth((y - m.b) / (n.a - m.b)); + } + return { d, name }; +} +let lastDusk = -1, lastState = ""; +function updateDusk(dur) { + if (!marks.length) return; + const { d, name } = hour(); + const dusk = root.dataset.mode === "dark" ? 1 : d; + if (Math.abs(dusk - lastDusk) > .001) { lastDusk = dusk; S("setDusk", dusk, dur); } + if (name !== lastState) { lastState = name; S("setState", name); } +} + +/* Words in the headline light up when the beam crosses them */ +const lumens = [...document.querySelectorAll(".lumen")]; +let lumRects = null; +function measureLumens() { + lumRects = lumens.map(el => { + const r = el.getBoundingClientRect(); + return { el, x: r.left + r.width / 2, y: r.top + r.height / 2, on: el.classList.contains("lit"), seen: r.bottom > 0 && r.top < innerHeight }; + }); +} +function lightWords(s) { + if (!lumRects) measureLumens(); + // (while the lighthouse reads the headline after the opening, the words it has lit stay lit; its weak light as it + // wakes lights none) + const I = s.beamI(), keep = s.readingWords(), lamp = s.lampOn() > .6 && s.lampPower() > .8; + for (const r of lumRects) { + const on = lamp && r.seen && s.inBeam(r.x, r.y) * (.45 + .55 * I) > .35; + if (on !== r.on && (on || !keep)) { r.on = on; r.el.classList.toggle("lit", on); } + } +} + +/* The night switch: the moon tile on the bar and the key's Night. Starts from the system setting, + remembers the visitor's choice, shared with the privacy and terms pages. */ const themeColor = document.querySelector('meta[name="theme-color"]'); -const modeButton = document.getElementById("mode"); -function setMode(mode) { +const nightSwitches = [document.getElementById("night"), document.querySelector(".key .night")]; +// (over a few seconds for the opening's sunrise: the drawing follows, and the browser's colour changes halfway) +function setMode(mode, dur) { root.dataset.mode = mode; - themeColor.content = mode === "light" ? "#f5f0e8" : "#15110d"; - modeButton.setAttribute("aria-pressed", mode === "dark"); + const color = mode === "light" ? "#86cbf2" : "#232662"; + if (dur) setTimeout(() => { themeColor.content = color; }, dur * 500); + else themeColor.content = color; + for (const b of nightSwitches) b.setAttribute("aria-pressed", mode === "dark"); + updateDusk(dur); } -setMode(root.dataset.mode); -modeButton.addEventListener("click", () => { +for (const b of nightSwitches) b.addEventListener("click", e => { + e.stopPropagation(); const mode = root.dataset.mode === "light" ? "dark" : "light"; localStorage.setItem("mode", mode); - if (document.startViewTransition && !reduce.matches) document.startViewTransition(() => setMode(mode)); - else setMode(mode); + setMode(mode); }); -addEventListener("resize", () => { fit(); dockState(); }); -document.fonts.ready.then(fit); +measureMarks(); +try { + if (window.Hill?.Scene) scene = new window.Hill.Scene(document.getElementById("scene"), { topInset: restHeight(), dusk: root.dataset.mode === "dark" ? 1 : hour().d }); +} catch (err) { + console.warn("The drawing didn't start:", err); + scene = null; +} +root.classList.toggle("has-scene", Boolean(scene)); + +if (scene) { + /* The strip behind the bar takes the colour of the sky it covers, so the bar reads over it as it does over the drawing */ + let lastSky = ""; + const lum = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4).reduce((a, c, i) => a + c * [.2126, .7152, .0722][i], 0); + const skyStrip = s => { + const sky = s.sky?.(); + if (!sky || sky === lastSky) return; + lastSky = sky; + dock.style.setProperty("--strip", sky); + dock.dataset.sky = lum(sky) < .18 ? "dark" : "light"; + }; + scene.onFrame = s => { + try { if (woken) lightWords(s); else placeSnore(s); if (woken && !rose) glowBar(s); sweepGlow(s); skyStrip(s); if (lit && s.playing) hideTour(); } catch (err) { + if (!sceneFailed) { sceneFailed = true; console.warn("The drawing failed:", err); } + } + }; + + /* The pointer and a finger steer the light and stir the grass, clouds and water, once the opening is over. + On touch only a sideways drag steers; the page keeps its vertical scrolling (touch-action: pan-y). */ + addEventListener("pointermove", e => { if (landed) S("pointer", e.clientX, e.clientY, e.pointerType); }, { passive: true }); + addEventListener("pointerdown", e => { if (landed) S("pointer", e.clientX, e.clientY, e.pointerType); }, { passive: true }); + addEventListener("pointerup", e => { if (e.pointerType !== "mouse") S("release"); }, { passive: true }); + addEventListener("pointercancel", () => S("release"), { passive: true }); + let swipe = null; + addEventListener("touchstart", e => { const t = e.touches[0]; swipe = t && { x: t.clientX, y: t.clientY, side: null }; }, { passive: true }); + addEventListener("touchmove", e => { + const t = e.touches[0]; + if (!t || !swipe) return; + if (swipe.side === null && Math.hypot(t.clientX - swipe.x, t.clientY - swipe.y) > 8) swipe.side = Math.abs(t.clientX - swipe.x) > Math.abs(t.clientY - swipe.y); + if (swipe.side && landed) S("pointer", t.clientX, t.clientY, "touch"); + }, { passive: true }); + addEventListener("touchend", () => { swipe = null; S("release"); }, { passive: true }); + addEventListener("touchcancel", () => { swipe = null; S("release"); }, { passive: true }); + root.addEventListener("mouseleave", () => S("release")); + addEventListener("blur", () => S("release")); + + /* A click or tap on the landscape, not on the page, drops something there. + A click that only closes a popup or a menu doesn't, nor one that ends the opening. */ + let overlay = false, pressedInOpening = false; + addEventListener("pointerdown", () => { pressedInOpening = !landed; }, true); + document.addEventListener("click", () => { overlay = Boolean(openId || adding || !ctx.hidden || reviewing); }, true); + document.addEventListener("click", e => { + if (overlay || editing || e.detail === 0 || !landed || pressedInOpening) return; + if (e.target.closest("a, button, input, label, pre, .note, .dock, [role='dialog']")) return; + if (String(getSelection?.() || "")) return; + S("tap", e.clientX, e.clientY); + }); + + /* The key's buttons drop the same surprises, for keyboards and screen readers too; Play ball starts the game */ + for (const b of document.querySelectorAll("[data-drop]")) b.addEventListener("click", () => S("surprise", b.dataset.drop)); + for (const b of document.querySelectorAll("[data-play]")) b.addEventListener("click", () => { hideTour(); S("play"); }); + scene.onBar = onBar; + + /* Past the last note the page goes on: once it has gone, scrolling takes you on over the sea, past the far island + and the ones beyond it (see ISLANDS in scene.js), through day and night (see hour). Well into the second round + of islands the page steps back a round, which looks the same, so it never ends; and as soon as you turn back, it + steps back to have its own hill just behind you, so the page is never more than an island away. The drawing and + the bar take the theme of the island you're heading to (onBar), and the visitor's again back on the page. */ + if (!reduce.matches) { + const round = window.Hill.islands; + let was = scrollY; + const diving = () => { + const n = laps(), back = n >= round + 2 ? round : scrollY < was ? S("behind") : 0; + if (back) scrollTo(0, scrollY - back * lap()); + was = scrollY; + S("setDive", Math.max(0, n - back), back); + }; + dive.hidden = false; + addEventListener("scroll", diving, { passive: true }); + diving(); + } +} + +/* ---------- The lighthouse plays with the bar ---------- + The ball hits a widget and it hops; a workspace number also takes the focus, as a click would, which lights + its beach hut. The bored lighthouse shoves one widget aside and puts it back. Nothing else changes: + Download never navigates, the night switch never toggles, and every hop is gone in half a second. */ +const widgetFor = key => key.startsWith("ws:") ? bar.querySelector(`#gl .wsi[data-ws="${key.slice(3)}"]`) : bar.querySelector(`:scope > .g > .w[data-key="${CSS.escape(key)}"]`); +let shove = null; +function unshove(quick) { + const s = shove; + if (!s) return; + shove = null; + if (!reduce.matches && s.el.isConnected) s.el.animate([{ transform: s.to }, { transform: "none" }], { duration: quick ? 240 : 760, easing: quick ? "cubic-bezier(.3, 0, .2, 1)" : "cubic-bezier(.45, 0, .25, 1)" }).finished.then(() => { if (shove?.el !== s.el) s.el.style.zIndex = ""; }, () => {}); + else s.el.style.zIndex = ""; + s.a.cancel(); +} +function onBar(event, key, info = {}) { + if (event === "release") return unshove(info.caught); + // out at sea, the bar goes over to the next island's theme as the drawing's does, the visitor's pick left alone + if (event === "theme") { barTheme(key); return fit(); } + const el = !editing && widgetFor(key); + if (!el) return; + if (event === "bump") { + if (key.startsWith("ws:")) focusWorkspace(Number(key.slice(3))); + if (!reduce.matches) el.animate([ + { transform: "none" }, + { transform: "translateY(-6px) rotate(-3deg)", offset: .26, easing: "ease-in" }, + { transform: "translateY(1px) scale(1.04, .94)", offset: .52 }, + { transform: "translateY(-1.5px) rotate(.6deg)", offset: .74 }, + { transform: "none" }, + ], { duration: 520, easing: "ease-out" }); + } else if (event === "nudge" && !reduce.matches) { + unshove(true); + const dx = info.dx || 14, to = `translateX(${dx}px) rotate(${Math.sign(dx) * 3}deg)`; + el.style.zIndex = "3"; + shove = { el, to, a: el.animate([{ transform: "none" }, { transform: to }], { duration: 620, easing: "cubic-bezier(.3, 1.5, .5, 1)", fill: "forwards" }) }; + } +} + +/* The opening: a title card over the whole drawing with the bar big in the middle of it, at night, the lighthouse + asleep and the drawing still. After a while it wakes up and strains to light its lamp, and its light finds the + card's bar, weak and unsteady, and crosses it. Then the day comes, and with it the page: the card's bar stretches up + into its place at the top, the card drifts away and the notes come in, while the lighthouse blinks its light out + and strains again. + Its light comes on for good as the headline comes in: it reads it, sweeps along the bar and looks at you (WAKE in + scene.js). On every load at the top, with motion allowed (the class and the night come from the page's head); until + the page has come in, a scroll, a key, a click or a tap hurries it in, the lighthouse awake. */ +let landed = !root.classList.contains("intro"), woken = landed, rose = landed, runTimer = 0, landTimer = 0, barIn = null; +const swept = new Map(); +const opening = document.getElementById("opening"), snore = document.getElementById("snore"); +const LAND_SKIP = ["wheel", "keydown", "pointerdown", "touchstart", "scroll"]; +const DAWN = 1.6; // seconds from night to day +const HURRY = 2; // times as fast as the show plays once hurried +const WAKE_AT = 1100, RISE_AFTER = 3600; // ms: asleep for a while, then awake at night, until the day comes +// it wakes into the day, or into the night when that's the visitor's choice on the night switch +const dayMode = () => localStorage.getItem("mode") === "dark" ? "dark" : "light"; +// the page has come in: at the end of the sunrise, or at once with the lighthouse awake +function land(skip) { + if (landed) return; + landed = true; + clearTimeout(runTimer); + clearTimeout(landTimer); + for (const type of LAND_SKIP) removeEventListener(type, hurry, true); + removeEventListener("visibilitychange", skipHidden); + if (skip) { root.classList.add("intro-skip"); setMode(dayMode()); } + root.classList.remove("intro", "op-bar-in", "op-page-in"); + opening.remove(); + measureMarks(); + lumRects = null; + sendBar(); // the bar's widgets where they stand, for the sweep and the ball game + S("labels", true); + if (skip) { wakeUp(true); requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("intro-skip"))); } + setTimeout(quietSpell); // the bored lighthouse's quiet spell starts now (not before the page's script has run) +} +// hurried: the lighthouse is awake at once and the rest of the show plays HURRY times as fast, the bar easing out at +// once if it hasn't left yet; once it's at the top, only its fade into the real bar is left, which keeps its pace +// (a click or a tap that hurries it doesn't also drop something on the landscape: see pressedInOpening) +function hurry() { + for (const type of LAND_SKIP) removeEventListener(type, hurry, true); + clearTimeout(runTimer); + wakeUp(true); + if (!rose) rise(true); + clearTimeout(landTimer); + if (!root.classList.contains("op-bar-in")) for (const a of opening.getAnimations({ subtree: true })) a.updatePlaybackRate(HURRY); + root.classList.add("op-page-in"); + barIn.then(() => land(false)); +} +// nobody watching: it comes in rather than play on unseen +function skipHidden() { if (document.hidden) land(true); } +// it wakes up: the z's go, the drawing comes to life, the eye opens and it strains to light its lamp on the card's +// bar, until the day comes (WAKE in scene.js); brought in at once (now), it's simply awake, even halfway through the +// show +function wakeUp(now) { + if (woken) { if (now === true) S("doze", false, true); return; } + woken = true; + snore.classList.add("gone"); + setTimeout(() => { root.classList.remove("asleep"); snore.remove(); }, 400); + S("freeze", false); + S("doze", false, now === true); + if (now === true) return; + const tiles = [...opening.querySelectorAll(".op-bar .w")].map(el => el.getBoundingClientRect()).filter(r => r.width), a = tiles[0], b = tiles.at(-1); + if (a) S("lightBar", a.left + a.width / 2, a.top + a.height / 2, b.left + b.width / 2, b.top + b.height / 2); + runTimer = setTimeout(rise, RISE_AFTER); +} +// then the day comes, and with it the page: the bar rises into place while the sky, the sun and the moon turn and the +// card changes with them; the lighthouse puts its weak light out, and its light comes on for good on the headline +function rise(fast) { + if (landed) return; + if (document.hidden) return land(true); + const ends = headlineEnds(); + rose = true; + for (const t of glowTiles || []) t.el.style.removeProperty("--glow"); // (its weak light goes out: see glowBar) + expandOpening(fast); + setMode(dayMode(), fast ? DAWN / HURRY : DAWN); + S("rising", ...(ends || [])); + landTimer = setTimeout(() => land(false), DAWN * 1000 + 100); +} +// the headline's first and last words in view, where they'll stand once the notes are in (they come in from a +// little lower: .intro .hero in style.css) +function headlineEnds() { + const note = lumens[0]?.closest(".hero > *"), dy = note ? parseFloat(getComputedStyle(note).translate.split(" ")[1]) || 0 : 0; + const seen = lumens.map(el => el.getBoundingClientRect()).filter(r => r.bottom - dy > 0 && r.top - dy < innerHeight); + if (!seen.length) return null; + const a = seen[0], b = seen.at(-1); + return [a.left + a.width / 2 - 10, a.top + a.height / 2 - dy, b.left + b.width / 2 + 10, b.top + b.height / 2 - dy]; +} +/* The card's bar at night: each of its widgets glows as brightly as the lighthouse's weak light on it, fading just + after the light has passed (--glow, in style.css) */ +let glowTiles = null; +function glowBar(s) { + const p = s.lampOn() * s.lampPower(); + if (!glowTiles) { + if (p <= 0) return; + glowTiles = [...opening.querySelectorAll(".op-bar .w")].map(el => { const r = el.getBoundingClientRect(); return { el, x: r.left + r.width / 2, y: r.top + r.height / 2, trail: 0, k: 0 }; }); + } + for (const t of glowTiles) { + t.trail = Math.max(s.inBeam(t.x, t.y), t.trail * .9); + const k = Math.round(Math.min(1, t.trail * p * 1.6) * 50) / 50; + if (k !== t.k) { t.k = k; t.el.style.setProperty("--glow", k); } + } +} +// the sleeper's z's, by its lamp wherever the drawing has put it +let snoreAt = ""; +function placeSnore(s) { + const p = s.lampAt(), size = Math.max(12, p.r * 1.1), key = `${Math.round(p.x)} ${Math.round(p.y)} ${Math.round(size)}`; + if (key === snoreAt) return; + snoreAt = key; + snore.style.cssText = `left: ${p.x + p.r * 2}px; top: ${p.y - p.r * 0.4 - size / 2}px; --zs: ${size}px`; +} +/* The card's bar stretches into the real one, as the day comes: a copy of it leaves the card and grows to the + window's width as it rises to the top, shrinking to the real bar's height, its widgets sliding apart as it widens. + The card's words drift away, the notes come in while it rises, and at the top the real bar fades in under it and + the copy fades away. The copy is made before the day comes, so its colours change with the sky's */ +function expandOpening(fast) { + const pill = opening.querySelector(".op-bar"), a = pill.getBoundingClientRect(), b = bar.getBoundingClientRect(), k = b.height / a.height; + const fly = pill.cloneNode(true); + fly.classList.add("op-fly"); + opening.append(fly); + getComputedStyle(fly).backgroundColor; + pill.style.visibility = "hidden"; + const grow = fly.animate([ + { left: `${a.left}px`, top: `${a.top}px`, width: `${a.width}px`, transform: "scale(1)", borderRadius: "22px" }, + { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width / k}px`, transform: `scale(${k})`, borderRadius: "0px" }, + ], { duration: 1100, easing: fast ? ease : "cubic-bezier(.45, 0, .25, 1)", fill: "forwards" }); + for (const el of opening.querySelectorAll(".op-mark, .op-line")) el.animate([{ opacity: 1, transform: "none" }, { opacity: 0, transform: "translateY(-28px)" }], { duration: 600, easing: "ease-in", fill: "forwards" }); + setTimeout(() => { if (!landed) root.classList.add("op-page-in"); }, 650); + barIn = grow.finished.then(() => { + if (landed) return; + root.classList.add("op-bar-in"); + return fly.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 340, fill: "forwards" }).finished; + }, () => {}); +} +/* While the light sweeps the bar, each widget it passes glows for a moment */ +function sweepGlow(s) { + const now = performance.now(); + if (s.sweepingBar()) for (const w of bar.querySelectorAll(".w[data-key]")) { + if (w.hidden || w.classList.contains("over") || !w.offsetWidth) continue; + const r = w.getBoundingClientRect(); + if (s.inBeam(r.left + r.width / 2, r.top + r.height / 2) > .45) { w.classList.add("swept"); swept.set(w, now + 420); } + } + for (const [w, until] of swept) if (now > until) { w.classList.remove("swept"); swept.delete(w); } +} +if (!landed) { + if (scrollY > 0 || document.hidden) land(true); + else { + for (const type of LAND_SKIP) addEventListener(type, hurry, { capture: true, passive: true }); + addEventListener("visibilitychange", skipHidden); + S("labels", false); + S("doze", true); // asleep, its eye shut and its lamp out, + S("freeze", true); // and the whole drawing still, + runTimer = setTimeout(wakeUp, WAKE_AT); // for a moment + } +} else { opening.remove(); snore.remove(); } + +/* The lamp-lit tour, for pointers that hover: the widget under the pointer takes the lamp's glow (the beam + already follows the pointer) and a paper tag hangs under it on a string. Keyboard focus shows it too. + It waits while the ball is in play, and never shows while editing, dragging or a popup is open. */ +const tag = document.getElementById("lamp-tag"); +const tagPaper = tag.querySelector(".lt-p"), tagText = tag.querySelector(".lt-t"), tagString = [...tag.querySelectorAll(".lt-s path")]; +const TAGS = { + ws: "four little rooms", apple: "still there", app: "Finder’s, for now", site: "this website, dressed as an app", + np: "the lighthouse hums along", meeting: "the keeper is late", system: "all calm", sound: "shh, the sheep are asleep", + battery: "the lamp runs on oil", clock: "always on time, unlike the keeper", dl: "the only serious button", + moon: "the lamp’s favourite", more: "the ones that didn’t fit", +}; +const canHover = matchMedia("(hover: hover)"); +let lit = null, tourShow = 0, tourHide = 0; +const tourWidget = el => { const w = el?.closest?.(".w[data-key]"); return w && bar.contains(w) && !w.closest(".lane") && TAGS[w.dataset.key] ? w : null; }; +const tourOK = () => scene && !editing && !openId && !drag && !adding && ctx.hidden && !scene.playing && !bar.classList.contains("swap"); +function showTour(w) { + clearTimeout(tourShow); + clearTimeout(tourHide); + if (!tourOK()) return; + const again = Boolean(lit); + lit?.classList.remove("lamp"); + lit = w; + w.classList.add("lamp"); + tagText.textContent = TAGS[w.dataset.key]; + tagPaper.style.setProperty("--r", `${(w.dataset.key.length * 7 % 5 - 2) * .7}deg`); + tag.hidden = false; + placeTag(); + if (!reduce.matches) tag.animate(again + ? [{ transform: "rotate(7deg)" }, { transform: "rotate(-3deg)", offset: .45 }, { transform: "none" }] + : [{ transform: "rotate(-16deg)", opacity: 0 }, { transform: "rotate(10deg)", opacity: 1, offset: .28 }, { transform: "rotate(-5deg)", offset: .52 }, { transform: "rotate(2deg)", offset: .76 }, { transform: "none" }], + { duration: again ? 420 : 850, easing: "ease-out" }); +} +/* The tag hangs from the middle of the widget's bottom edge; the paper keeps inside the window and the + string goes through a hole above the widget, wherever the paper ends up */ +function placeTag() { + const r = lit.getBoundingClientRect(), cx = r.left + r.width / 2, len = 13, pw = tagPaper.offsetWidth; + const left = clamp(cx - pw / 2, 8, innerWidth - pw - 8) - cx, hole = clamp(-left, 14, pw - 14); + tag.style.left = `${cx}px`; + tag.style.top = `${r.bottom - 1}px`; + tagPaper.style.left = `${left}px`; + tagPaper.style.top = `${len}px`; + tagPaper.style.setProperty("--hole", `${hole}px`); + const hx = left + hole, hy = len + 7.5; + for (const p of tagString) p.setAttribute("d", `M0 -1C${hx * .2 + 1.8} ${hy * .4} ${hx * .75 - 1.4} ${hy * .62} ${hx} ${hy}`); +} +function hideTour() { + clearTimeout(tourShow); + clearTimeout(tourHide); + if (!lit) return; + lit.classList.remove("lamp"); + lit = null; + tag.hidden = true; +} +bar.addEventListener("pointerover", e => { + if (e.pointerType === "touch" || !canHover.matches) return; + const w = tourWidget(e.target); + if (!w) return; + clearTimeout(tourHide); + if (w === lit) return; + clearTimeout(tourShow); + if (lit) showTour(w); + else tourShow = setTimeout(() => showTour(w), 240); +}); +bar.addEventListener("pointerout", e => { + const w = tourWidget(e.target); + if (!w || w.contains(e.relatedTarget)) return; + clearTimeout(tourShow); + if (w === lit) tourHide = setTimeout(hideTour, 140); +}); +bar.addEventListener("focusin", e => { const w = tourWidget(e.target); if (w && e.target.matches(":focus-visible")) showTour(w); }); +bar.addEventListener("focusout", e => { if (lit && !lit.contains(e.relatedTarget) && !lit.matches(":hover")) hideTour(); }); + +/* The bored lighthouse: once a visit, after a quiet spell with no pointer, scroll or key, and nothing open, + counted from the end of the opening. The scene checks the rest (motion allowed, the lamp in view, no game on, + its light not busy); any input catches it. */ +let boredTimer = 0, boredDone = false; +function quietSpell() { + clearTimeout(boredTimer); + if (!scene || boredDone || !landed) return; + boredTimer = setTimeout(function check() { + const calm = !editing && !openId && !adding && ctx.hidden && !reviewing && !lit && !document.hidden && !reduce.matches; + if (calm && S("bored")) boredDone = true; + else boredTimer = setTimeout(check, 4000); + }, 12000); +} + +/* ---------- The pen: every note, Download button, tag and key button gets a felt-pen outline, + drawn at its size in three slightly different states that the CSS steps through (the boil). + Inline SVG paths rather than images, so an outline never counts as the page's largest paint. ---------- */ +const INK = ".note, .cta, .tag, .keys button, .w.dl, .lt-p"; +const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; +const noise = x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return (hash(i) + (hash(i + 1) - hash(i)) * u) * 2 - 1; }; +const r1 = v => Math.round(v * 10) / 10; +const PAD = 8; + +/* The torn top of the notebook page: one tooth every 12 px or so, the same each time */ +function teeth(W, seed) { + const n = Math.max(6, Math.round(W / 12)), out = []; + for (let i = 0; i <= n; i++) out.push([W * i / n, 2 + 8 * hash(seed + i * 1.7) * (i % 2 ? 1 : .45)]); + return out; +} +/* The centre line of an outline, clockwise, starting a little way along the top */ +function perimeter(W, H, shape, radii, seed) { + const pts = [], step = 6; + const seg = (ax, ay, bx, by) => { + const n = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / step)); + for (let i = 0; i < n; i++) pts.push([ax + (bx - ax) * i / n, ay + (by - ay) * i / n]); + }; + const arc = (cx, cy, r, a0, a1) => { + const n = Math.max(2, Math.round(r * Math.abs(a1 - a0) / step)); + for (let i = 0; i < n; i++) { const a = a0 + (a1 - a0) * i / n; pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); } + }; + const [tl, tr, br, bl] = radii.map(r => Math.min(r, W / 2, H / 2)); + const x0 = PAD, y0 = PAD, x1 = PAD + W, y1 = PAD + H, Q = Math.PI / 2; + if (shape === "torn") { + const top = teeth(W, seed).map(([x, y]) => [x0 + x, y0 + y]); + for (let i = 0; i < top.length - 1; i++) seg(...top[i], ...top[i + 1]); + seg(x1, top.at(-1)[1], x1, y1 - br); arc(x1 - br, y1 - br, br, 0, Q); + seg(x1 - br, y1, x0 + bl, y1); arc(x0 + bl, y1 - bl, bl, Q, 2 * Q); + seg(x0, y1 - bl, x0, top[0][1]); + for (let i = 0; i < 3; i++) pts.push(top[i]); + return pts; + } + const sx = x0 + tl + (W - tl - tr) * (.12 + .2 * hash(seed)); + seg(sx, y0, x1 - tr, y0); arc(x1 - tr, y0 + tr, tr, -Q, 0); + seg(x1, y0 + tr, x1, y1 - br); arc(x1 - br, y1 - br, br, 0, Q); + seg(x1 - br, y1, x0 + bl, y1); arc(x0 + bl, y1 - bl, bl, Q, 2 * Q); + seg(x0, y1 - bl, x0, y0 + tl); arc(x0 + tl, y0 + tl, tl, 2 * Q, 3 * Q); + seg(x0 + tl, y0, Math.min(sx + 26, x1 - tr), y0); + return pts; +} +/* A felt-pen stroke: a ribbon that wobbles a little, swells and thins, and tapers where the pen lifts. + The three boil states share the shape and differ only in the fine jitter. */ +function outline(W, H, shape, radii, seed, boil, weight) { + const c = perimeter(W, H, shape, radii, seed), n = c.length, L = [], R = [], j = seed + boil * 9.1; + for (let i = 0; i < n; i++) { + const [ax, ay] = c[Math.max(0, i - 1)], [bx, by] = c[Math.min(n - 1, i + 1)]; + const len = Math.hypot(bx - ax, by - ay) || 1, nx = -(by - ay) / len, ny = (bx - ax) / len; + const off = noise(i * .031 + seed * 7.7) * 1.4 + noise(i * .21 + j * 3.1) * .55; + const ends = Math.min(1, (i + 1) / 7, (n - i) / 6); + const w = weight * (.8 + .22 * noise(i * .07 + seed * 5.3 + 40) + .1 * noise(i * .3 + j * 2.3)) * (.3 + .7 * ends) / 2; + const [x, y] = c[i]; + L.push([x + nx * (off + w), y + ny * (off + w)]); + R.push([x + nx * (off - w), y + ny * (off - w)]); + } + const ring = [...L, ...R.reverse()]; + let d = `M${r1(ring[0][0])} ${r1(ring[0][1])}`; + for (let i = 1; i < ring.length; i++) { + const [x, y] = ring[i], [nx, ny] = ring[(i + 1) % ring.length]; + d += `Q${r1(x)} ${r1(y)} ${r1((x + nx) / 2)} ${r1((y + ny) / 2)}`; + } + return `${d}Z`; +} +function ink(el, seed) { + const W = el.offsetWidth, H = el.offsetHeight; + if (!W || !H || el.dataset.inkSize === `${W}x${H}`) return; + el.dataset.inkSize = `${W}x${H}`; + const shape = el.dataset.ink === "torn" ? "torn" : el.matches(".cta, .keys button, .w.dl") ? "pill" : "rect"; + const cs = getComputedStyle(el); + const radii = shape === "pill" ? [H / 2, H / 2, H / 2, H / 2] + : [cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius].map(v => parseFloat(v) || 0); + const weight = el.matches(".note") ? 2.9 : el.matches(".w.dl, .lt-p") ? 2.1 : 2.5; + let svg = el.querySelector(":scope > .pen"); + if (!svg) { + svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "pen"); + svg.setAttribute("aria-hidden", "true"); + el.append(svg); + } + svg.setAttribute("viewBox", `0 0 ${W + PAD * 2} ${H + PAD * 2}`); + svg.innerHTML = [0, 1, 2].map(k => ``).join(""); + if (shape === "torn") el.style.setProperty("--torn", `polygon(${teeth(W, seed).map(([x, y]) => `${r1(x)}px ${r1(y)}px`).join(", ")}, 100% 100%, 0 100%)`); + el.classList.add("inked"); +} +const inked = [...document.querySelectorAll(INK)]; +const seeds = new Map(inked.map((el, i) => [el, 1 + i * 2.3])); +const pen = new ResizeObserver(entries => { for (const e of entries) ink(e.target, seeds.get(e.target)); }); +for (const el of inked) pen.observe(el); + +/* The boil rests after a few quiet seconds, like the drawing */ +let idleTimer = 0, lastWake = -1e9; +function wake() { + const now = performance.now(); + if (now - lastWake < 250) return; + lastWake = now; + root.classList.remove("idle"); + clearTimeout(idleTimer); + idleTimer = setTimeout(() => root.classList.add("idle"), 6000); + S("poke"); + quietSpell(); +} +for (const type of ["pointermove", "pointerdown", "keydown", "scroll", "wheel", "touchstart"]) addEventListener(type, wake, { passive: true }); +wake(); + +/* ---------- Start ---------- */ +let queued = false; +addEventListener("scroll", () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + dockState(true); + updateDusk(); + lumRects = null; + }); +}, { passive: true }); +addEventListener("resize", () => { + hideTour(); + fit(); + dockState(); + measureMarks(); + updateDusk(); + lumRects = null; + S("setTopInset", restHeight()); +}); +document.fonts.ready.then(() => { + fit(); + measureMarks(); + updateDusk(); + lumRects = null; +}); +setMode(root.dataset.mode); setTheme("built-in"); +focusWorkspace(2); fit(); dockState(); - -/* Arrival: the headline settles, the bar's widgets come in from the left, then the windows and the agenda */ -bar.querySelectorAll(".g > .w").forEach((w, i) => w.style.setProperty("--i", i)); -requestAnimationFrame(() => requestAnimationFrame(() => root.classList.add("arrived"))); -if (innerWidth >= 720) setTimeout(() => { if (!openId && !editing) open("agenda"); }, reduce.matches ? 0 : 1100); +updateDusk(); +})(); diff --git a/site/privacy.html b/site/privacy.html index 71874028..54a9a047 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -6,14 +6,16 @@ Privacy Policy · StableBar - + - + +
StableBar diff --git a/site/scene.js b/site/scene.js new file mode 100644 index 00000000..87f27017 --- /dev/null +++ b/site/scene.js @@ -0,0 +1,3522 @@ +/* Lighthouse Hill, in absurd ink: the scene behind stablebar.app. + A one-eyed lighthouse in a pom-pom hat on a hill above the sea, drawn in Canvas 2D by one + felt-pen hand: tapered ink ribbons that boil a little, and flat colours printed slightly off + the line. main.js creates one scene and forwards input to it (the API is at the end). */ +(() => { +'use strict'; + +/* ---------- small maths ---------- */ +const TAU = Math.PI * 2; +const clamp = (v, a = 0, b = 1) => (v < a ? a : v > b ? b : v); +const lerp = (a, b, t) => a + (b - a) * t; +const smooth = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); }; +const easeOut = t => t * (2 - t); +const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; +const noise = x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return lerp(hash(i), hash(i + 1), u) * 2 - 1; }; +const rng = seed => { let a = (seed >>> 0) || 1; return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }; +const hex = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; +const mix = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; +const mul = (a, b) => [a[0] * b[0] / 255, a[1] * b[1] / 255, a[2] * b[2] / 255]; +const rgba = (c, a = 1) => { if (TINT) c = TINT(c); return `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${a})`; }; +const angDiff = (a, b) => { let d = (a - b) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; }; +// the light's turn that faces you (its rotation at a quarter turn), nearest to where it is +const facing = phi => Math.PI / 2 + TAU * Math.round((phi - Math.PI / 2) / TAU); +const stops = list => list.map(([d, p]) => [d, Object.fromEntries(Object.entries(p).map(([k, v]) => [k, hex(v)]))]); +function palAt(list, d) { + let i = 0; + while (i < list.length - 2 && d > list[i + 1][0]) i++; + const [d0, p0] = list[i], [d1, p1] = list[i + 1], t = clamp((d - d0) / (d1 - d0)); + const out = {}; + for (const k in p0) out[k] = mix(p0[k], p1[k], t); + return out; +} +/* Catmull-Rom through control points, then evenly spaced points every `step` px */ +function curve(ctrl, step, closed = false) { + const p = closed ? [ctrl[ctrl.length - 1], ...ctrl, ctrl[0], ctrl[1]] : [ctrl[0], ...ctrl, ctrl[ctrl.length - 1]]; + const raw = []; + for (let i = 1; i < p.length - 2; i++) { + const p0 = p[i - 1], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2]; + for (let s = 0; s < 8; s++) { + const t = s / 8, t2 = t * t, t3 = t2 * t; + const f = k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3); + raw.push([f(0), f(1)]); + } + } + if (!closed) raw.push(ctrl[ctrl.length - 1].slice()); + return dens(raw, step, closed); +} +/* Points every `step` px along a polyline */ +function dens(pts, step, closed = false) { + const out = [], src = closed ? [...pts, pts[0]] : pts; + for (let i = 0; i < src.length - 1; i++) { + const [x0, y0] = src[i], [x1, y1] = src[i + 1]; + const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step)); + for (let k = 0; k < n; k++) out.push([lerp(x0, x1, k / n), lerp(y0, y1, k / n)]); + } + if (!closed) out.push(src[src.length - 1].slice()); + return out; +} +const ellipse = (cx, cy, rx, ry, n = 32, a0 = 0, a1 = TAU, rot = 0) => { + const out = [], c = Math.cos(rot), s = Math.sin(rot); + for (let i = 0; i < n; i++) { + const a = lerp(a0, a1, i / (a1 - a0 >= TAU - 1e-6 ? n : n - 1)), x = Math.cos(a) * rx, y = Math.sin(a) * ry; + out.push([cx + x * c - y * s, cy + x * s + y * c]); + } + return out; +}; +function poly(c, pts, close = true, dx = 0, dy = 0) { + c.beginPath(); + for (let i = 0; i < pts.length; i++) i ? c.lineTo(pts[i][0] + dx, pts[i][1] + dy) : c.moveTo(pts[i][0] + dx, pts[i][1] + dy); + if (close) c.closePath(); +} +function inPoly(pts, x, y) { + let ins = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [xi, yi] = pts[i], [xj, yj] = pts[j]; + if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi) ins = !ins; + } + return ins; +} +/* A cumulus: the upper envelope of a few bumps over a flat base */ +function cloudPts(cx, cy, w, seed) { + const R = rng(seed), n = 4 + ((R() * 2) | 0), bumps = []; + for (let i = 0; i < n; i++) { + const fx = (i + 0.5) / n; + bumps.push([cx - w / 2 + fx * w + (R() - 0.5) * w * 0.06, w * (0.14 + R() * 0.1) * (1.3 - Math.abs(fx - 0.5))]); + } + const x0 = cx - w / 2 - w * 0.05, x1 = cx + w / 2 + w * 0.05, top = []; + for (let i = 0; i <= 64; i++) { + const x = lerp(x0, x1, i / 64); + let y = cy; + for (const [bx, r] of bumps) { const dx = x - bx; if (Math.abs(dx) < r) y = Math.min(y, cy - Math.sqrt(r * r - dx * dx) * 0.92); } + top.push([x, y]); + } + return top.filter((p, i) => p[1] < cy - 0.3 || i === 0 || i === top.length - 1); +} + +/* ---------- colours ---------- */ +/* Five hours of the day. The felt pen stays black; only the printed colours move. */ +const PAL = stops([ + [0, { sky: '#86CBF2', sky2: '#B8E2F8', sea: '#2E58D8', sea2: '#8DB0FF', foam: '#FFFFFF', hill: '#83CF5F', hill2: '#66B24C', ink: '#111111', paper: '#FFFFFF', beam: '#FFE04A', cloud: '#FFFFFF', far: '#A2C9EC', glass: '#B8E2F8' }], + [0.2, { sky: '#A6B6F4', sky2: '#E6C4EE', sea: '#2E50CC', sea2: '#93AAFF', foam: '#FFFFFF', hill: '#7CC95E', hill2: '#5FA84B', ink: '#111111', paper: '#FFFBF4', beam: '#FFE04A', cloud: '#FFFFFF', far: '#B9B4E6', glass: '#E6C4EE' }], + [0.44, { sky: '#FF8F73', sky2: '#FFC07F', sea: '#3043B8', sea2: '#9EA3FF', foam: '#FFF1E6', hill: '#6DB65A', hill2: '#539A4A', ink: '#111111', paper: '#FFF7EC', beam: '#FFE04A', cloud: '#FFE1CF', far: '#D88BA0', glass: '#FFC07F' }], + [0.7, { sky: '#6A4CC4', sky2: '#B0609C', sea: '#22287A', sea2: '#6E77D8', foam: '#D9CCFF', hill: '#3E7D56', hill2: '#2F6447', ink: '#111111', paper: '#F0E6FF', beam: '#FFE24E', cloud: '#C3B2EE', far: '#5E4B9A', glass: '#FFD86A' }], + [1, { sky: '#232662', sky2: '#30357E', sea: '#14184A', sea2: '#4A54B4', foam: '#9EA6E8', hill: '#244F3E', hill2: '#1B3F31', ink: '#0B0B14', paper: '#E8E2FF', beam: '#FFE65C', cloud: '#565C9E', far: '#2E3270', glass: '#FFE65C' }], +]); +const STEPS = 48; // palette steps that the cached layers are keyed on + +/* The lighthouse's pyjamas, one per StableBar theme: stripes and hat */ +const THEMES = { + 'built-in': { stripes: ['#FF4A36'], hat: '#FF4A36' }, + catppuccin: { stripes: ['#CBA6F7'], hat: '#CBA6F7' }, + gruvbox: { stripes: ['#FABD2F', '#FE8019', '#FB4934'], hat: '#FE8019' }, + leman: { stripes: ['#5CC5B8'], hat: '#5CC5B8' }, + nord: { stripes: ['#88C0D0'], hat: '#88C0D0' }, + notch: { stripes: ['#111111'], hat: '#111111' }, + phosphor: { stripes: ['#FFB000'], hat: '#FFB000' }, +}; +const STATES = ['hero', 'edit', 'themes', 'widgets', 'tiling', 'install']; +const KINDS = ['sheep', 'cow', 'duck', 'monster', 'gull']; +let C = { + wool: hex('#FFFFFF'), yolk: hex('#FFC21A'), duck: hex('#FFD21F'), beak: hex('#FF7A1A'), pink: hex('#FF9CB0'), + monster: hex('#FF8FC0'), chimney: hex('#E8452F'), roof: hex('#2B2B36'), door: hex('#2E58D8'), skin: hex('#F6C9A6'), + navy: hex('#25305E'), wood: hex('#C98B4A'), steel: hex('#D8DCE6'), moon: hex('#FFE89A'), tile: hex('#1D1D24'), iris: hex('#FFB52E'), lamp: hex('#FFD45C'), + huts: ['#FF4A36', '#2E58D8', '#FFC21A', '#36A852'].map(hex), + kraft: hex('#CC9A5E'), kraftLit: hex('#E2B983'), kraftDark: hex('#A87442'), tape: hex('#FFE04A'), sprout: hex('#2E9B3B'), +}; +// animals that live on the hill (not riding a cloud): they wander and graze +const grazer = o => (o.kind === 'sheep' || o.kind === 'cow') && !o.ride; +/* The sheep's box, in its own units (times boxK): the front face's half size, the depth seen past it, and + three air holes punched in the front, the first two with eyes behind them */ +const BOX = { hw: 21, hh: 13, dx: 9, dy: -7, holes: [[-11, -1.5], [0.5, -3.5], [11.5, -0.5]] }; +// a rect [x0, y0, x1, y1] against a rect, or a disc [x, y, r] +const overlaps = (a, q) => q.length === 3 + ? Math.hypot(clamp(q[0], a[0], a[2]) - q[0], clamp(q[1], a[1], a[3]) - q[1]) < q[2] + : a[0] < q[2] && a[2] > q[0] && a[1] < q[3] && a[3] > q[1]; +const boxK = s => Math.max(s.g.u, 0.72) * 1.12; +let HAND = '"Shantell Sans Hand", "Shantell Sans", "Chalkboard SE", sans-serif', WEIGHT = 400, CAPS = false; + +/* ---------- the pen ---------- + One felt pen in three weights. Every stroke is a filled ribbon whose width follows the pen's + pressure: thin where it lands and lifts, a little uneven along the way. The line boils between + three states; the printed fills don't boil and sit one constant vector off the line. These are the felt pen's; + the pen, the fills, the colours and the font in use are the style's being drawn (see STYLES). */ +let BOIL = 0, PW = 1, INK = '#111', REGX = 2.6, REGY = 1.9, NAP = false, TINT = null, ribbon = feltRibbon, print = feltPrint; +const SIL = 3.2, DET = 2.1, TEX = 1.35; + +function feltRibbon(c, pts, w, sd = 0, o = {}) { + const n = pts.length; + if (n < 2) return; + const len = new Float64Array(n); + for (let i = 1; i < n; i++) len[i] = len[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); + const total = len[n - 1] || 1, bo = BOIL * 7.13 + sd * 3.71; + const amp = (o.amp ?? 0.85) * PW, tin = Math.min(total * 0.4, (o.tin ?? 9) * PW), tout = Math.min(total * 0.5, (o.tout ?? 15) * PW); + const w0 = o.w0 ?? 0.4, w1 = o.w1 ?? 0.22, hw0 = w * PW * 0.5; + const lx = new Float64Array(n), ly = new Float64Array(n), rx = new Float64Array(n), ry = new Float64Array(n); + let ex = 0, ey = 0, etx = 0, ety = 0, ehw = 0, sx = 0, sy = 0, stx = 0, sty = 0, shw = 0; + for (let i = 0; i < n; i++) { + const a = pts[i > 0 ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : n - 1]; + let tx = b[0] - a[0], ty = b[1] - a[1]; + const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; + const s = len[i], off = amp * (noise(s / 70 + bo) * 0.9 + noise(s / 19 + bo * 1.7 + 5) * 0.28); + const x = pts[i][0] - ty * off, y = pts[i][1] + tx * off; + const p = lerp(w0, 1, easeOut(clamp(s / tin))) * lerp(w1, 1, easeOut(clamp((total - s) / tout))) * (1 + 0.15 * noise(s / 43 + bo * 0.6 + 17)); + const hw = hw0 * p; + lx[i] = x - ty * hw; ly[i] = y + tx * hw; rx[i] = x + ty * hw; ry[i] = y - tx * hw; + if (i === 0) { sx = x; sy = y; stx = tx; sty = ty; shw = hw; } + if (i === n - 1) { ex = x; ey = y; etx = tx; ety = ty; ehw = hw; } + } + c.moveTo(lx[0], ly[0]); + for (let i = 1; i < n; i++) c.lineTo(lx[i], ly[i]); + for (let k = 1; k < 5; k++) { const th = k / 5 * Math.PI, co = Math.cos(th), si = Math.sin(th); c.lineTo(ex + ehw * (-ety * co + etx * si), ey + ehw * (etx * co + ety * si)); } + for (let i = n - 1; i >= 0; i--) c.lineTo(rx[i], ry[i]); + for (let k = 1; k < 5; k++) { const th = k / 5 * Math.PI, co = Math.cos(th), si = Math.sin(th); c.lineTo(sx + shw * (sty * co - stx * si), sy + shw * (-stx * co - sty * si)); } + c.closePath(); +} +/* A single stroke in ink */ +function ink(c, pts, w, sd, o) { c.beginPath(); ribbon(c, pts, w, sd, o); c.fillStyle = o?.color || INK; c.fill(); } +/* A closed contour drawn in one go, ending a little past where it started */ +function loop(c, pts, w, sd = 0, o = {}) { + const n = pts.length, k = Math.abs(Math.round(sd * 7919)) % n, over = Math.max(2, Math.round(n * (o.over ?? 0.08))); + let cx = 0, cy = 0; + for (const p of pts) { cx += p[0]; cy += p[1]; } + cx /= n; cy /= n; + const out = []; + for (let i = 0; i <= n + over; i++) { + const p = pts[(k + i) % n]; + if (i <= n) out.push(p); + else { const f = (i - n) / over * 1.6 * PW, dx = p[0] - cx, dy = p[1] - cy, dl = Math.hypot(dx, dy) || 1; out.push([p[0] + dx / dl * f, p[1] + dy / dl * f]); } + } + ribbon(c, out, w, sd, { tin: 5, tout: 12, w0: 0.55, w1: 0.12, ...o, outline: true }); +} +/* Straight edges, each its own stroke running a little past the corners */ +function edges(c, corners, w, sd = 0, closed = true, o = {}) { + const m = corners.length, ov = (o.ov ?? 2.6) * PW; + for (let i = 0; i < (closed ? m : m - 1); i++) { + const [x0, y0] = corners[i], [x1, y1] = corners[(i + 1) % m], l = Math.hypot(x1 - x0, y1 - y0) || 1, ux = (x1 - x0) / l, uy = (y1 - y0) / l; + const a = hash(sd * 3.1 + i) * ov, b = hash(sd * 5.7 + i + 0.5) * ov; + ribbon(c, dens([[x0 - ux * a, y0 - uy * a], [x1 + ux * b, y1 + uy * b]], 5), w, sd + i * 1.7, { tin: 4, tout: 6, w0: 0.6, w1: 0.45, ...o, outline: true }); + } +} +/* A printed fill, off the line by the register vector */ +function feltPrint(c, pts, col) { poly(c, pts, true, REGX, REGY); c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill(); } +function shape(c, pts, col, w = DET, sd = 0, o) { print(c, pts, col); c.beginPath(); loop(c, pts, w, sd, o); c.fillStyle = INK; c.fill(); } +function dot(c, x, y, r, col) { c.beginPath(); c.arc(x, y, r, 0, TAU); c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill(); } +function label(c, text, x, y, size, col, align = 'left', rot = -0.035) { + c.save(); c.translate(x, y); c.rotate(rot); + c.font = `${WEIGHT} ${size}px ${HAND}`; c.textAlign = align; c.textBaseline = 'middle'; c.fillStyle = col; + c.fillText(CAPS ? text.toUpperCase() : text, 0, 0); c.restore(); +} + +/* ---------- the scene: geometry, state, input and time ---------- */ +const IDLE_AFTER = 4000, IDLE_FPS = 14, BOIL_FPS = 7, MAX_OBJS = 36; +// a new style comes in as the light turns once round, in this many seconds (see setTheme) +const SWAP = 1.2; +// out at sea, the page's theme follows the islands, each coming in over this many seconds (see update) +const WIPE = 0.9; +// the opening's wake-up, in seconds. At night, from when its eye starts to open: it opens halfway (to `lids.mid` shut) +// by `lids.half`, stays a moment, droops a little (to `lids.heavy`) by `lids.sag`, then opens all the way by `open`, +// easing out. Then it strains to light its lamp, squeezing its eye shut for each of `pushes` ([when, how long, how +// hard]), and from `dim` its light finds the card's bar, weak and unsteady (nearly out in each of `dips`), and crosses +// it (`scan`: [when, how long]; both counted from `dim`). When the day comes (from the page's rising): it blinks its +// light out over `out`, strains again (`again`), and its light comes on for good at `on`, on the headline's first +// word; it reads the headline from `read` for `dur`, and at `end` it sweeps the bar +const WAKE = { + open: 1.45, lids: { half: 0.5, hold: 0.6, sag: 0.85, mid: 0.62, heavy: 0.84 }, + pushes: [[1.55, 0.26, 0.75], [1.93, 0.32, 0.9]], dim: 2.29, scan: [0.25, 0.9], + dips: [[0.22, 0.27], [0.52, 0.58], [0.63, 0.67], [0.88, 0.93], [1.14, 1.19]], + out: 0.14, again: [[0.24, 0.28, 0.85], [0.62, 0.36, 1]], on: 0.98, read: 1.22, dur: 1.1, end: 2.45, +}; +// a visit at the cottage's door, in seconds from the third knock in a row: it opens over `open`, the keeper is in the doorway from +// `keeper`[0] to [1], looking out to the left and from `right` to the right; he wonders (`huh`), turns his back at `turn` +// and walks back in; it shuts over `shut`, and it's over at `end` (`away`, when he's out on the ridge and nobody comes) +const VISIT = { open: [0.55, 0.8], keeper: [0.7, 2.95], right: 1.6, huh: 2.2, turn: 2.4, shut: [2.97, 3.22], end: 3.3, away: 1.6 }; +// the grass on the hill: at most this many tufts (fewer on phones), a new one every 3 to 6 s while there are fewer +const MEADOW_MAX = 14, MEADOW_PHONE = 8, SPROUT_MIN = 3, SPROUT_MAX = 6; +/* The dive past the end of the page: you go straight on toward the middle of the horizon, past islands that stand + one after another, DIVE_GAP times the nearest one's distance apart, off to one side and then the other. They come + round in this order (the first one is the page's far island); each is a ridge and a shore, as fractions of the width + and, up from the horizon, of the sky or, down from it, of the sea. `lx` is the lighthouse, `cx` the cottage. Each + wears one of the other themes: its lighthouse's pyjamas, and its ground by day (lit and shady), darker with the hour + like the page's green. */ +// (an island seen at its own size stands this far down the sea, as a part of it: it's as far off as the sea there) +const DIVE_GAP = 7, SHORE = 0.27; +const ISLANDS = [ + // on the right, a snowy peak: the lighthouse on its shoulder, the cottage on a ledge above it + { theme: 'nord', ground: ['#E8EDF4', '#BFCBDB'], + ridge: [[0.52, 0.12], [0.56, 0.02], [0.6, -0.08], [0.65, -0.1], [0.69, -0.13], [0.74, -0.24], [0.79, -0.38], [0.82, -0.42], [0.85, -0.37], [0.9, -0.24], [0.95, -0.1], [0.99, 0.12]], + shore: [[0.52, 0.12], [0.6, 0.21], [0.72, 0.26], [0.86, 0.27], [0.95, 0.22], [0.99, 0.12]], lx: 0.63, cx: 0.71 }, + // on the left, a table mountain: steep all round, flat on top + { theme: 'gruvbox', ground: ['#B8BB26', '#98971A'], + ridge: [[0.02, 0.12], [0.04, 0], [0.06, -0.12], [0.08, -0.17], [0.13, -0.18], [0.2, -0.18], [0.27, -0.17], [0.33, -0.16], [0.36, -0.11], [0.38, -0.02], [0.4, 0.12]], + shore: [[0.02, 0.12], [0.08, 0.22], [0.2, 0.26], [0.32, 0.23], [0.4, 0.12]], lx: 0.3, cx: 0.17 }, + // on the right, two round hills: the cottage in the dip between them, the lighthouse on the far one + { theme: 'catppuccin', ground: ['#F5C2E7', '#DDA3CF'], + ridge: [[0.53, 0.12], [0.56, 0], [0.6, -0.1], [0.64, -0.15], [0.68, -0.13], [0.72, -0.07], [0.76, -0.06], [0.8, -0.11], [0.85, -0.19], [0.9, -0.2], [0.95, -0.12], [0.99, 0.12]], + shore: [[0.53, 0.12], [0.6, 0.22], [0.72, 0.26], [0.85, 0.27], [0.95, 0.22], [0.99, 0.12]], lx: 0.88, cx: 0.74 }, + // on the left, a long slope up from a beach to a cliff over the middle, the lighthouse on its edge + { theme: 'leman', ground: ['#7CCDB4', '#5AB198'], + ridge: [[0, 0.12], [0.04, 0.06], [0.1, 0], [0.17, -0.05], [0.24, -0.1], [0.3, -0.15], [0.35, -0.19], [0.38, -0.2], [0.4, -0.14], [0.41, -0.02], [0.42, 0.12]], + shore: [[0, 0.12], [0.08, 0.2], [0.2, 0.24], [0.33, 0.24], [0.42, 0.12]], lx: 0.355, cx: 0.18 }, + // on the right, a small dark crag: the lighthouse on top, the cottage at its foot + { theme: 'notch', ground: ['#4A4B55', '#36373F'], + ridge: [[0.6, 0.12], [0.62, 0.04], [0.66, 0], [0.7, -0.02], [0.73, -0.12], [0.75, -0.26], [0.78, -0.32], [0.82, -0.31], [0.85, -0.22], [0.88, -0.08], [0.91, 0.12]], + shore: [[0.6, 0.12], [0.66, 0.2], [0.76, 0.24], [0.86, 0.22], [0.91, 0.12]], lx: 0.8, cx: 0.67 }, + // on the left, wide and low, sand dunes + { theme: 'phosphor', ground: ['#F2C46A', '#D9A548'], + ridge: [[0, 0.12], [0.03, 0.02], [0.08, -0.05], [0.13, -0.07], [0.18, -0.04], [0.23, -0.06], [0.28, -0.11], [0.33, -0.12], [0.38, -0.08], [0.43, -0.02], [0.46, 0.04], [0.48, 0.12]], + shore: [[0, 0.12], [0.07, 0.22], [0.2, 0.27], [0.36, 0.25], [0.45, 0.19], [0.48, 0.12]], lx: 0.31, cx: 0.13 }, +]; +/* What each island has of its own: its light, its animals, grass and box, its games with the bar (the page's + only). One island's state at a time lives on the scene (see enter); the others wait in their world objects. */ +const islandState = () => ({ + beam: { a: Math.PI * 0.96, len: 1, flash: 0, phi: Math.PI * 0.96, steer: 0, sa: Math.PI * 0.96 }, + wakePath: null, gaze: null, restAt: -99, + ripples: [], objs: [], + // the ball game and the bored trick (see "the lighthouse plays with the bar") + bar: [], ball: null, bore: null, look: null, sweep: null, + doodles: [], splashes: [], + hatKick: -9, regrowAt: -9, + // someone at the cottage's door (see knock) + visit: null, knocks: { n: 0, at: -9 }, + // a lived-in hill: the animals already there, the grass they eat, and a sheep in a box up in the sky + flocked: false, meadow: [], sproutAt: 0, + box: { v: null, gx: null, gy: null, side: null, lastSide: null, la: 0, seenAt: -9, hop: -9, wob: -9, nextWob: 0, peek: -9, lit: 0, open: false, openAt: -9, blinkAt: -9, nextBlink: 0, knocks: 0, knockAt: -9, hopK: 1, out: null, burst: 0, lid: 0, sheep: null }, +}); +const ISLAND = ['g', ...Object.keys(islandState())]; +// the islands are drawn between these sizes: from far off, where they come out of the haze (by then the next island is +// all there is to see, the one after it comes as you go), to going past, off the screen +const FAR = 0.07, PAST = 30; +// the far island seen from the page is in the page's style; it changes into its own theme's over this much of the +// dive's first lap (see drawScene) +const OWN = 0.1; +// the lighthouse standing on `base` at x, h tall to its gallery, the eye in its lamp er across: its lamp and hat +function tower(g, x, base, h, er) { + const T = g.lh = { x, base, h, er, gal: er * 0.62, top: base - h, wT: er * 1.62, wB: er * 2.6 }; + g.lamp = { x, y: T.top - T.gal - er * 1.02 }; + T.hatTop = g.lamp.y - er * 3.6; +} + +/* ---------- styles ---------- + Each theme draws the whole scene in its own medium: the same hill, animals, gags and hours, another hand. The felt + pen is the built-in theme's. Each of the others has a file in styles/ that registers its style, after this file and + before the page makes the scene: + + Hill.style('nord', { pal, C, tint, ribbon, pen, boil, print, reg, hand, weight, caps, fps, before, after, cached, draw }); + + Every lever is optional; what a style leaves out is the felt pen's (Hill.felt). + pal the hours: stops like PAL's, made with Hill.stops, with the same keys. `ink` is the line's colour. The + islands' ground darkens with the hour as `hill` and `hill2` do from the first stop, so neither may + have a channel at 0 there. + C the colour constants it changes, made with Hill.hex; the others stay. + tint a colour [r, g, b] in, a colour out, wherever a colour is written out (rgba): every fill and stroke, + cached ones too, but not the few glints written as '#FFFFFF'. For greys, or all in one colour. + ribbon the line: (c, pts, w, sd, o) adds a stroke along the points, w wide (times PW), to the path, and the + caller fills it, mostly in INK. ink, loop, edges, shape and most drawings go through it. sd seeds its + wobble; o holds the felt pen's taper and wobble (tin, tout, w0, w1, amp), which another line may + ignore, and says what the stroke is: o.outline goes round a shape the caller fills (loop's and edges', + so shape's too, and a few drawn on their own), o.hatch is shading. An empty one draws no lines at all. + pen the line's weight, times the felt pen's (1). + boil how many times a second the line moves on, between three states (7); 0 holds it still. The cached + layers are drawn once for each state. + print the fill: (c, pts, col) fills the polygon REGX, REGY off the line, col a colour or a CSS string. + reg that offset, [x, y] in pen units ([2.6, 1.9]); [0, 0] prints the fills on the line. + hand the font of the labels and doodles, a CSS font family list. For a web font, add its FontFace to + document.fonts: the first text drawn in it fetches it, and when it's in, the scene draws again and + redraws its cached layers (they're keyed on fontsV). + weight the labels' and doodles' font weight (400). + caps true sets the labels and doodles in capitals, and measures them so where they're placed. + fps draws at this many frames a second: the drawing's time (s.t) is stepped, the world's isn't (s.clock), + so things still go where they go between the steps. + before (s, c) at the start of each frame, after the levers are on; after (s, c) at its end, over everything + (grain, scanlines, fibre). Both in the scene's CSS pixels. + cached (s, c) once a cached layer or sprite is drawn, on its own canvas, before it's kept: what it adds there + costs nothing per frame (a glow). + draw { name(base, ...args) }: any of the drawings named in Hill.felt.draw, in place of the felt pen's, which comes + as base to call or not. The drawings call each other through DRAW, so an override is used wherever + its drawing is, by the felt ones too. + A drawing gets the scene s and the canvas c (the animals get c, u, their colour, o and t, and obj gets s): s.g the + geometry (W, H, u, pw, top, horizon, ridge, shore, hillPoly, hillClip, lh the tower, lamp, cot the cottage, + clouds, sheepCloud, stars, tufts), s.P the palette now, s.t the time, s.d the hour (0 day, 1 night), s.style the + style on, s.theme the visitor's theme, s.beam, s.pres how far in each section's scene is, s.reduced. Lengths are CSS + pixels of the scene; sizes go in u (g.u, 1 on a window of about 1138 by 1138) and pens in pw. The islands of the + dive use the same drawings in their own s.g, on a canvas scaled by view, each in its own theme's style (but the + far one seen from the page, in the visitor's), while the sky, the sea and the clouds are in the page's: the + visitor's, and out at sea the theme of the island you're heading to (s.shows). That one comes in as a circle + opening out from the middle of the horizon (s.wipe): meanwhile the sky, the sea, the clouds, before and after are + drawn in both styles, the old one's outside the circle and the new one's inside it. The band behind the horizon + (one for each theme the page is in) and the hill with its cottage and tower are cached layers, the sun, the moon + and the clouds sprites: those drawings run only when the hour's step, the boil state, the style, the size or the + fonts change, so they can't move on their own. Drawings leave the scene as they found it, save the cottage, which + puts its door in g.cot.door for the visit and the doorway: one drawn without base must too. Every style keeps the + lighthouse's pyjamas: THEMES[g.theme || s.theme]. */ +const FELT = { + id: 'built-in', pal: PAL, C, ribbon: feltRibbon, print: feltPrint, pen: 1, boil: BOIL_FPS, reg: [2.6, 1.9], hand: HAND, weight: 400, caps: false, + draw: { + scene: drawScene, sky: drawSky, band: drawBand, stars: drawStars, waves: drawWaves, ripples: drawRipples, sun: drawSun, + moon: drawMoon, halo: drawHalo, beam: drawBeam, cloud: drawCloud, land: drawLand, front: drawFront, hill: drawHill, + cottage: drawCottage, tower: drawTower, hillLight: drawHillLight, tufts: drawTufts, visit: drawVisit, smoke: drawSmoke, + eye: drawEye, hat: drawHat, box: drawBox, leap: drawLeap, meadow: drawMeadow, obj: drawObj, sheep: drawSheep, + cow: drawCow, gull: drawGull, duck: drawDuck, monster: drawMonster, states: drawStates, washing: drawWashing, + huts: drawHuts, desk: drawDesk, doorway: drawDoorway, editKeeper: drawEditKeeper, keeper: drawKeeper, tile: drawTile, + labels: drawLabels, splashes: drawSplashes, ball: drawBall, doodles: drawDoodles, + }, +}; +const STYLES = { 'built-in': FELT }; +let DRAW = FELT.draw; +function style(id, st) { + const own = st.draw || {}; + STYLES[id] = { ...FELT, ...st, id, C: { ...FELT.C, ...st.C }, draw: Object.fromEntries(Object.entries(FELT.draw).map(([k, f]) => [k, own[k] ? (...a) => own[k](f, ...a) : f])) }; +} + +class Scene { + constructor(host, opts = {}) { + this.o = Object.assign({ topInset: 0, dusk: 0, seed: 11 }, opts); + this.host = host; + const cv = this.canvas = document.createElement('canvas'); + cv.setAttribute('aria-hidden', 'true'); + cv.style.cssText = 'position:absolute;left:0;top:0;display:block;pointer-events:none'; + host.appendChild(cv); + this.ctx = cv.getContext('2d'); + this.fade = document.createElement('canvas').getContext('2d'); + this.top = Math.max(0, +this.o.topInset || 0); + this.t = 0; this.d = this.dTarget = clamp(+this.o.dusk || 0); this.dTween = null; this.labelsAt = -9; + // the visitor's theme, and the one the page's sky, sea and clouds are in: out at sea, the next island's (see update) + this.state = 'hero'; this.theme = this.shows = 'built-in'; this.ws = 1; + this.pres = Object.fromEntries(STATES.map(k => [k, k === 'hero' ? 1 : 0])); + this.since = Object.fromEntries(STATES.map(k => [k, 0])); + // the pointer in client coordinates less the scene's offset; each island sees it as this.ptr, in its own (see enter) + this.cursor = { x: 0, y: 0, on: false, last: -1e9, kind: 'mouse' }; + this.vel = { x: 0, y: 0 }; + this.wind = 0; this.windAmb = 0.3; this.windTotal = 0.3; this.drift = 0; + this.blink = { next: 2.5, at: -9, wink: -9 }; + this.layers = {}; + this.sprites = new Map(); + this.fontsV = 0; this.layoutV = 0; this.rebuilds = 0; + this.onFrame = null; + this.onBar = null; + this.held = !!this.o.held; + this.dozing = false; this.wokeAt = -9; this.waking = false; this.nightPath = this.riseAt = null; this.popped = false; this.frozen = false; + // the islands, by how far into the dive they are: 0 is the page's hill, whose state is on the scene to begin with + Object.assign(this, islandState()); + this.dive = 0; this.turn = 0; + this.cur = this.w0 = { view: { s: 1, x: 0, y: 0 } }; + this.worlds = new Map([[0, this.w0]]); + this.times = []; + this.awake = 0; this.kick = 0; this.raf = 0; this.timer = 0; this.last = null; + this.mq = matchMedia('(prefers-reduced-motion: reduce)'); + this.reduced = this.o.reduced ?? this.mq.matches; + this.onMQ = () => { if (this.o.reduced == null) { this.reduced = this.mq.matches; if (this.reduced) { this.still(); this.awake = 0; } this.wake(); } }; + this.mq.addEventListener?.('change', this.onMQ); + this.frame = this.frame.bind(this); + this.measure(true); + this.ro = new ResizeObserver(() => { if (this.measure()) this.wake(); }); + this.ro.observe(host); + this.onVis = () => { if (document.hidden) { this.endBall(); this.endBore(); this.sleep(); } else { this.last = null; this.wake(); } }; + document.addEventListener('visibilitychange', this.onVis); + const fontsIn = () => { this.fontsV++; this.wake(); }; + if (document.fonts) { + document.fonts.load(`400 16px ${HAND}`).then(fontsIn, () => {}); + document.fonts.addEventListener?.('loadingdone', fontsIn); + } + this.wake(); + } + + /* ----- page state ----- */ + // with a duration (the opening's sunrise), an even change over that time instead of the usual quick ease + setDusk(d, dur = 0) { + d = clamp(+d || 0); + if (dur > 0 && !this.reduced) this.dTween = { from: this.d, a: 0, dur }; + else if (d !== this.dTarget) this.dTween = null; + this.dTarget = d; if (this.reduced) this.d = d; this.wake(); + } + setState(name) { + if (!STATES.includes(name)) return; + if (name !== this.state) { + this.state = name; this.since[name] = this.t; + // a new little scene may stand where an animal rests: resting ones look around again shortly + for (const o of this.objs) if (grazer(o) && o.act === 'idle') o.until = Math.min(o.until, this.t + 0.6 + Math.random() * 1.2); + } + this.wake(); + } + // once the scene is up, the new style comes in with the light: the last frame, in the old one, stays where the light + // hasn't been yet (see swap); without motion, at once; out at sea, the page has it when you're back (see update) + setTheme(id) { + if (!THEMES[id]) return; + if (id !== this.theme && this.t > 0.5 && !this.reduced && !this.dive) { + const x = this.was ||= document.createElement('canvas').getContext('2d'); + x.canvas.width = this.canvas.width; x.canvas.height = this.canvas.height; + x.drawImage(this.canvas, 0, 0); + this.swap = { t0: this.t, a0: this.beam.a, a: this.beam.a, L: this.g.lamp }; + } + this.theme = id; + if (!this.dive && id !== this.shows) this.show(id); + this.wake(); + } + // the page's sky, sea and clouds in another theme from now on; the band cached in the one before goes + show(id) { delete this.layers['back' + this.shows]; this.shows = id; } + setWorkspace(n) { this.ws = clamp(Math.round(+n || 1), 1, 4); this.wake(); } + setTopInset(px) { const v = Math.max(0, +px || 0); if (v !== this.top) { this.top = v; this.layout(); } this.wake(); } + // how far past the end of the page, in laps, one island a lap. + // `back`: the page stepped back that many laps (a round of ISLANDS, or, going back up, to put its own hill just + // behind you), and the islands ahead are renumbered to match, the round going on as it was (see turn) + setDive(laps, back = 0) { + if (back) { + const was = this.worlds; + this.worlds = new Map([[0, this.w0]]); + for (const [n, w] of was) if (n > back) this.worlds.set(n - back, w); + this.turn = (this.turn + back) % ISLANDS.length; + } + if (laps === this.dive) return; + this.dive = laps; + this.wake(); + } + + /* ----- the islands of the dive ----- */ + world(n) { + let w = this.worlds.get(n); + if (!w) { + const g = this.island(ISLANDS[(n - 1 + this.turn) % ISLANDS.length]); + w = { g, ...islandState() }; + this.worlds.set(n, w); + } + return w; + } + // the islands in sight, the farthest first, each with where it is on the screen (screen = s * point + (x, y)): seen + // from its distance, around the middle of the horizon, from the one going past to the ones far off + shown() { + const m = Math.floor(this.dive), x = this.back.W / 2, y = this.back.horizon, out = []; + for (let n = m + 6; n >= m; n--) { + const s = 1 / (1 + (n - this.dive) * DIVE_GAP); + if (s > FAR && s < PAST) out.push(Object.assign(this.world(n), { view: { s, x: x * (1 - s), y: y * (1 - s) } })); + } + return out; + } + // how many laps the page can step back to have its own hill as the island just behind you: all of them but the + // one still going past, if there is one + behind() { + const m = Math.floor(this.dive), s = 1 / (1 - (this.dive - m) * DIVE_GAP); + return Math.max(0, s > 0 && s < PAST ? m - 1 : m); + } + // the island being looked at, the nearest that isn't going past: input goes to it + focus() { return this.shown().findLast(w => w.view.s <= 1.6); } + // puts an island's state on the scene, and the pointer in its coordinates (an island far off doesn't see it yet) + enter(w) { + if (w !== this.cur) { + for (const k of ISLAND) { this.cur[k] = this[k]; this[k] = w[k]; } + this.cur = w; + } + const v = w.view, p = this.cursor; + this.ptr = { ...p, x: (p.x - v.x) / v.s, y: (p.y - v.y) / v.s, on: p.on && v.s > 0.45 }; + } + // an island of the dive, on the page's sky and sea (see ISLANDS) + island(I) { + const b = this.back, { W, u, top, horizon: hz } = b, sky = hz - top, sea = b.H - hz, was = this.g; + const at = ([fx, fy]) => [fx * W, hz + fy * (fy < 0 ? sky : sea)]; + const g = this.g = { ...b, isle: true, theme: I.theme, ground: I.ground.map(hex), ridge: curve(I.ridge.map(at), 6), shore: curve(I.shore.map(at), 6), note: { x0: 0, x1: 0, y0: 0, y1: 0 }, key: null }; + const lx = I.lx * W, h = sky * 0.5, er = clamp(h * 0.062, 11.5, 22), cx = I.cx * W, cw = Math.min(h * 0.3, W * 0.13); + // the lighthouse and the cottage stand on level ground, cut into the slope + for (const [x, w] of [[lx, er * 2.6], [cx, cw]]) { + const y = this.ridgeY(x); + g.ridge = g.ridge.map(([px, py]) => [px, lerp(y, py, smooth(w * 0.55, w * 1.2, Math.abs(px - x)))]); + } + const ins = 2.6 * g.pw; + g.hillPoly = [...g.ridge, ...[...g.shore].reverse()]; + g.hillClip = [...g.ridge.map(([x, y]) => [x, y + ins]), ...g.shore.slice().reverse().map(([x, y]) => [x, y - ins])]; + g.land = [g.ridge[0][0] + 20 * u, g.ridge.at(-1)[0] - 20 * u, (I.lx + I.cx) / 2 * W]; + tower(g, lx, this.ridgeY(lx) + 2 * u, h, er); + g.cot ={ x: cx, base: this.ridgeY(cx) + 3 * u, w: cw, h: cw * 0.62 }; + g.tufts = this.tufts(rng(this.o.seed + cx)); + this.g = was; + return g; + } + destroy() { + this.dead = true; this.sleep(); this.ro.disconnect(); + this.mq.removeEventListener?.('change', this.onMQ); + document.removeEventListener('visibilitychange', this.onVis); + this.canvas.remove(); + } + + /* ----- what the page reads after every frame ----- */ + inBeam(x, y) { + x -= this.ox; y -= this.oy; + const b = this.beam, L = this.g.lamp, dx = x - L.x, dy = y - L.y, dist = Math.hypot(dx, dy); + if (dist < 6 || dist > b.len * this.diag || b.len < 0.08) return 0; + const w = this.g.beamHalf + 10 * this.g.u / dist; + return 1 - smooth(w * 0.7, w * 1.2, Math.abs(angDiff(Math.atan2(dy, dx), b.a))); + } + beamI() { return Math.max(0.3 + 0.7 * smooth(0.22, 0.72, this.d), this.pres.install); } + lamp() { return { x: this.g.lamp.x + this.ox, y: this.g.lamp.y + this.oy }; } + // the colour of the sky at the top of the drawing right now, as #rrggbb + sky() { const c = this.P && (TINT ? TINT(this.P.sky) : this.P.sky); return c ? '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('') : null; } + + /* ----- input, in client coordinates ----- */ + pointer(x, y, kind = 'mouse') { + x -= this.ox; y -= this.oy; + const now = performance.now() / 1000, p = this.cursor; + if (p.on && now - p.last < 0.25) { + const dt = Math.max(0.008, now - p.last); + this.vel.x = lerp(this.vel.x, (x - p.x) / dt, 0.35); + this.vel.y = lerp(this.vel.y, (y - p.y) / dt, 0.35); + } + Object.assign(p, { x, y, on: true, last: now, kind: kind || 'mouse' }); + this.enter(this.cur); + ({ x, y } = this.ptr); + const sp = Math.hypot(this.vel.x, this.vel.y); + if (!this.reduced && sp > 650 && this.isSea(x, y) && now - (this.lastRip || 0) > 0.11) { + this.lastRip = now; + this.ripples.push({ x, y, t0: this.t, a: Math.min(1, sp / 2400) * 0.9 }); + } + if (this.reduced) this.aim(x, y); + this.poke(); + this.wake(); + } + release() { this.cursor.on = this.ptr.on = false; this.wake(); } + // to the island under it, the nearest first, or else to the one being looked at + tap(x, y) { + Object.assign(this.cursor, { x: x - this.ox, y: y - this.oy }); + const w = this.shown().reverse().find(w => { this.enter(w); const p = this.ptr; return this.isLand(p.x, p.y) || this.onTower(p.x, p.y) || this.boxHit(p.x, p.y); }) || this.focus(); + this.enter(w); + const hit = this.tapAt(this.ptr.x, this.ptr.y); + this.enter(this.focus()); + return hit; + } + onTower(x, y) { const T = this.g.lh; return y >= this.top && Math.abs(x - T.x) < T.wB * 0.75 && y > T.hatTop - T.er * 1.6 && y < T.base; } + tapAt(x, y) { + this.poke(); + // the lighthouse plays ball instead of catching things (and winks when it can't) + if (this.onTower(x, y)) { + if (!this.play()) this.blink.wink = this.t; + this.wake(); + return false; + } + // the sheep in its box says baa (and the light turns to see who's there); nothing drops + if (this.boxHit(x, y)) { + this.aim(x, y); + this.baa(); + this.wake(); + return false; + } + // the cottage's door: knock knock, and the keeper comes to see who's there; nothing drops + if (this.doorHit(x, y)) { + this.aim(x, y); + this.knock(); + this.wake(); + return false; + } + this.aim(x, y); + this.wake(); + if (y < this.top) return false; + this.spawn(x, y); + return true; + } + surprise(kind) { + if (!KINDS.includes(kind)) return; + const p = this.spot(kind); + const o = p.cloud ? this.ride(kind, p.cloud) : this.spawn(p.x, p.y, kind, false, p.fromSky); + if (o.kind === 'gull' && !this.reduced) { o.vx *= 0.5; o.calm = 1.6; } + this.wake(); + } + aim(x, y) { + const L = this.g.lamp; + Object.assign(this.ptr, { x, y, on: true, last: performance.now() / 1000 }); + this.beam.sa = this.clampAim(Math.atan2(y - L.y, x - L.x)); + if (this.reduced) this.beam.steer = 1; + } + clampAim(a) { + // keep the light off the lighthouse's own feet: never closer than this to straight down + const lim = 0.14, down = Math.PI / 2; + if (a > down - lim && a <= down) return down - lim; + if (a > down && a < down + lim) return down + lim; + return a; + } + + /* ----- the lighthouse plays with the bar ----- + Keepy-uppy: the pom-pom leaves the hat, bounces off the line under a widget, comes back and is + headed up again, three to six times, then misses and drops into the sea. The bored trick: once, + when nobody has done anything for a while (the page decides), the beam shoves a widget aside. + The page sends the bar's widgets, animates them on onBar(event, key, info) and reads s.playing. */ + get playing() { return Boolean(this.ball); } + // the bar's visible widgets, in client coordinates: { key, x0, x1, y }, y the line the ball bounces on. + // Only the page's lighthouse plays with them: the islands of the dive have no bar. + setBar(list) { + const was = this.cur; + this.enter(this.w0); + this.bar = (Array.isArray(list) ? list : []).filter(t => t && t.x1 > t.x0) + .map(t => ({ key: String(t.key), x0: t.x0 - this.ox, x1: t.x1 - this.ox, y: t.y - this.oy })); + if (!this.bar.length) { this.endBall(); this.endBore(); } + this.enter(was); + this.wake(); + } + // the landing: until the camera has come down, the flock and the grass wait (they're placed where they're seen) + hold(on) { this.held = !!on; if (!on) this.measure(); this.wake(); } + // the opening's wake-up (see WAKE), at night: the card's bar its weak light finds and crosses, from one end to the + // other (viewport coordinates) + lightBar(x0, y0, x1, y1) { + if (!this.showing()) return false; + const L = this.g.lamp, at = (x, y) => Math.atan2(y - this.oy - L.y, x - this.ox - L.x); + this.nightPath = { a0: at(x0, y0), a1: at(x1, y1), p0: [x0 - this.ox, y0 - this.oy], p1: [x1 - this.ox, y1 - this.oy] }; + this.wake(); + return true; + } + // how far across the card's bar its weak light has come (0 to 1) + scanK() { const s0 = WAKE.dim + WAKE.scan[0]; return smooth(s0, s0 + WAKE.scan[1], this.t - this.wokeAt); } + // how far its light reaches, as part of its full length: at night, in the show, only just past the card's bar, + // as if the bar caught it; else all the way + reach() { + const np = this.nightPath; + if (!np || !this.showing() || (this.riseAt != null && this.t - this.riseAt >= WAKE.out)) return 1; + // where the light meets the bar's line, along where it points now + const L = this.g.lamp, [x0, y0] = np.p0, vx = np.p1[0] - x0, vy = np.p1[1] - y0, ux = Math.cos(this.beam.a), uy = Math.sin(this.beam.a); + const den = ux * vy - uy * vx, d = Math.abs(den) < 1e-6 ? Math.hypot(x0 - L.x, y0 - L.y) : ((x0 - L.x) * vy - (y0 - L.y) * vx) / den; + return clamp((Math.max(0, d) + 50 * this.g.u) / (this.diag * 1.05)); + } + // then the day comes: it blinks its light out and strains again, and its light comes on for good on the headline, + // from one point to the other, and reads it; then it sweeps the bar and looks at you (with no headline, it only + // lights up) + rising(x0, y0, x1, y1) { + if (!this.showing() || this.riseAt != null) return false; + const L = this.g.lamp, at = (x, y) => Math.atan2(y - this.oy - L.y, x - this.ox - L.x); + this.riseAt = this.blink.at = this.t; + this.wakePath = x0 == null ? null : { t0: this.t, a0: at(x0, y0), a1: at(x1, y1) }; + this.wake(); + return true; + } + // reading the headline: the words it has lit stay lit until it has swept the bar too + readingWords() { return !!this.wakePath || this.sweepingBar(); } + // sweeping the bar: the page lights the widgets it passes + sweepingBar() { return !!this.sweep?.bar; } + // the opening: asleep, its eye shut and its lamp out, until the page wakes it for the show (see WAKE), or at once + // (now), its light coming on as the eye opens. Woken at once during the show, it gives it up and its light comes on. + doze(on, now) { + if (on) this.dozing = true; + else if (this.dozing) { + this.dozing = false; this.wokeAt = this.t; this.restAt = this.t; this.waking = !now && !this.reduced; + this.nightPath = this.riseAt = null; this.popped = false; + this.blink.next = this.t + (this.waking ? 12 : 4); // its own blinks once it's done (sooner if it looks at you) + } else if (now && this.showing()) { this.waking = false; this.wakePath = null; this.blink.next = this.t + 2; } + this.wake(); + } + // 1 while asleep, easing to 0 as the eye opens: in the show halfway, a little heavier again, then all the way (see + // WAKE); quickly when woken at once + drowsy() { + if (this.dozing) return 1; + const a = this.t - this.wokeAt, out = k => Math.sin(clamp(k) * Math.PI / 2), W = WAKE.lids; + if (!this.waking) return 1 - out(a / 0.45); + if (a < W.half) return lerp(1, W.mid, out(a / W.half)); + if (a < W.hold) return W.mid; + if (a < W.sag) return lerp(W.mid, W.heavy, smooth(W.hold, W.sag, a)); + return lerp(W.heavy, 0, out((a - W.sag) / (WAKE.open - W.sag))); + } + // the opening's show is on: from waking up until its light comes on for good + showing() { return this.waking && !(this.riseAt != null && this.t - this.riseAt >= WAKE.on); } + // how hard it squeezes its eye shut right now, straining to light its lamp (0 to 1) + squeeze() { + if (!this.showing() || this.reduced) return 0; + const night = this.riseAt == null, a = this.t - (night ? this.wokeAt : this.riseAt); + for (const [p, d, k] of night ? WAKE.pushes : WAKE.again) if (a >= p && a < p + d) return k * smooth(p, p + 0.06, a) * (1 - smooth(p + d - 0.07, p + d, a)); + return 0; + } + // how far its light reaches (0 to 1): out while asleep; in the show, from `dim` at night until the day's `out`, then + // from `on`; else on once the eye is half open + lampOn() { + if (this.dozing) return 0; + if (!this.waking) return 1 - smooth(0, 0.6, this.drowsy()); + const dim = smooth(WAKE.dim, WAKE.dim + 0.15, this.t - this.wokeAt), b = this.t - this.riseAt; + return this.riseAt == null ? dim : b < WAKE.out ? dim : smooth(WAKE.on, WAKE.on + 0.12, b); + } + // how bright its light is (0 to 1): in the show, at night, weak and unsteady, nearly out now and then, and going out + // as the day comes; else full + lampPower() { + if (!this.waking || (this.riseAt != null && this.t - this.riseAt >= WAKE.on)) return 1; + const f = this.t - this.wokeAt - WAKE.dim; + const weak = WAKE.dips.some(([d0, d1]) => f >= d0 && f < d1) ? 0.1 : 0.5 + 0.1 * Math.sin(f * 31) * Math.sin(f * 13 + 1); + return this.riseAt == null ? weak : weak * (1 - smooth(0, WAKE.out, this.t - this.riseAt)); + } + // the opening's still night: nothing moves until the visitor does (the page draws the sleeper's z's) + freeze(on) { this.frozen = !!on; if (!on) this.last = null; this.wake(); } + // where the lamp is in the window, and the eye's size: the page puts the z's there + lampAt() { const L = this.g.lamp; return { x: this.ox + L.x, y: this.oy + L.y, r: this.g.lh.er }; } + // the drawing's own labels ("a lighthouse (stable)" and the others), off while the page's title card is up + labels(on) { if (on && this.noLabels) this.labelsAt = this.t; this.noLabels = !on; this.wake(); } + // how far the labels have come back in (they fade in) + labelsK() { return this.noLabels ? 0 : this.reduced ? 1 : smooth(0, 0.6, this.t - this.labelsAt); } + // the landing's last beat: the light swings to the bar's left end and sweeps along it to the right, once + sweepBar(dur = 1) { + const L = this.g.lamp, ts = this.bar; + if (this.reduced || !ts.length || document.hidden) return false; + const y = Math.min(...ts.map(t => t.y)) - 12 * this.g.u, x0 = Math.min(...ts.map(t => (t.x0 + t.x1) / 2)), x1 = Math.max(...ts.map(t => (t.x0 + t.x1) / 2)); + this.beam.sa = this.beam.a; this.beam.steer = 1; + this.sweep = { t0: this.t, lead: 0.5, dur, tail: 0.25, bar: true, gaze: true, from: this.beam.a, a0: Math.atan2(y - L.y, x0 - L.x), a1: Math.atan2(y - L.y, x1 - L.x) }; + this.wake(); + return true; + } + play() { + if (this.reduced || this.ball || document.hidden || this.showing()) return false; + const h = hatGeom(this), pr = h.pr; + if (!this.bar.some(t => t.y + pr < h.bob[1] - pr) || !this.seen(this.g.lamp.x, this.g.lamp.y)) return false; + this.endBore(); + this.ball = { x: h.bob[0], y: h.bob[1], vx: 0, vy: 0, n: 0, max: 3 + Math.floor(Math.random() * 4), key: null, last: null, spin: 0, sq: -9 }; + this.regrowAt = -9; + this.serve(); + this.wake(); + return !!this.ball; + } + // any input: catches the bored trick in the act + poke() { + const b = this.bore; + if (!b || (b.phase !== 'look' && b.phase !== 'push')) return; + if (b.phase === 'push') this.emit('release', b.key, { caught: true }); + b.phase = 'caught'; b.at = this.t; b.notes = 0; + this.wake(); + } + // the bored trick, now (the page calls it after a quiet spell; also a hook for testing). True if it started. + bored() { + // (not while its light is busy, nor in the first 40 s after the opening, counted from the end of its look at + // you, or from waking up when the opening was skipped: it has only just performed) + if (this.reduced || this.ball || this.bore || this.sweep || this.wakePath || this.gaze || this.t - this.restAt < 40 || document.hidden) return false; + const L = this.g.lamp, mid = t => (t.x0 + t.x1) / 2; + const near = this.bar.filter(t => /^(clock|meeting|battery)$/.test(t.key)).sort((a, b) => Math.abs(mid(a) - L.x) - Math.abs(mid(b) - L.x))[0]; + if (!near || !this.seen(L.x, L.y)) return false; + const x = mid(near); + this.bore = { key: near.key, x, y: Math.max(4, this.top * 0.5), side: x < L.x ? -1 : 1, phase: 'look', at: this.t, notes: 0 }; + this.wake(); + return true; + } + emit(event, key, info = {}) { + try { this.onBar?.(event, key, info); } catch (err) { if (!this.onBarFailed) { this.onBarFailed = true; console.warn('onBar failed:', err); } } + } + // the next widget: the one nearest the pointer when it's up near the bar, otherwise another one, never the same twice + pick() { + const b = this.ball, g = this.g, h = hatGeom(this), L = g.lamp, mid = t => (t.x0 + t.x1) / 2; + const reach = this.bar.filter(t => t.y + h.pr < h.bob[1] - h.pr && t.x1 > 0 && t.x0 < g.W); + if (!reach.length) return null; + if (this.ptr.on && this.ptr.y < Math.max(g.top + 60 * g.u, g.H * 0.34)) { + const px = this.ptr.x, gap = t => px < t.x0 ? t.x0 - px : px > t.x1 ? px - t.x1 : 0; + return reach.slice().sort((p, q) => gap(p) - gap(q) || Math.abs(mid(p) - px) - Math.abs(mid(q) - px))[0]; + } + const other = reach.filter(t => t.key !== b.last); + const list = other.length ? other : reach, w = list.map(t => 1 / (1 + Math.abs(mid(t) - L.x) / (500 * g.u + 200))); + let r = Math.random() * w.reduce((a, v) => a + v, 0); + for (let i = 0; i < list.length; i++) if ((r -= w[i]) <= 0) return list[i]; + return list[list.length - 1]; + } + // up from the hat to the next widget + serve() { + const b = this.ball, tg = this.pick(); + if (!tg) return this.endBall(); + const pr = this.g.lh.er * 0.66, span = tg.x1 - tg.x0; + b.key = tg.key; + this.leg('up', clamp((tg.x0 + tg.x1) / 2 + (Math.random() - 0.5) * span * 0.36, pr, this.g.W - pr), tg.y + pr); + } + /* One flight, along a parabola: 'up' slows down and still rises when it arrives (the bounce), + 'down' and 'miss' speed up as they fall back, 'fall' drops freely into the sea */ + leg(kind, bx, by, v0) { + const b = this.ball, R = by - b.y, T = clamp(0.4 + Math.abs(bx - b.x) / 1600, 0.42, 1); + const L = { kind, t0: this.t, ax: b.x, ay: b.y, bx, by, T }; + if (kind === 'up') { const k = 0.3; L.v0 = 2 * R / (T * (1 + k)); L.g = (k - 1) * L.v0 / T; } + else if (kind === 'fall') { + const g = 1300 * this.g.u, v = Math.max(0, v0 || 0); + L.v0 = v; L.g = g; L.T = (-v + Math.sqrt(v * v + 2 * g * Math.max(1, R))) / g; + } else { L.v0 = 0.3 * R / T; L.g = 2 * (R - L.v0 * T) / (T * T); } + b.leg = L; + } + legAt(L, k) { return [L.ax + (L.bx - L.ax) * k / L.T, L.ay + L.v0 * k + 0.5 * L.g * k * k]; } + updateBall(dt) { + const b = this.ball; + if (!b) return; + const L = b.leg, k = Math.min(this.t - L.t0, L.T); + [b.x, b.y] = this.legAt(L, k); + b.vx = (L.bx - L.ax) / L.T; b.vy = L.v0 + L.g * k; + b.spin += dt * b.vx / (this.g.lh.er * 0.66) * 0.5; + if (this.t - L.t0 < L.T) return; + if (L.kind === 'up') this.hit(); + else if (L.kind === 'down') this.header(); + else if (L.kind === 'miss') this.fall(); + else this.splash(); + } + // the ball meets the line under a widget: the widget hops, "boink!" + hit() { + const b = this.ball, g = this.g, u = g.u, pr = g.lh.er * 0.66; + const tg = this.bar.find(t => t.key === b.key && b.x > t.x0 - pr && b.x < t.x1 + pr) || this.bar.find(t => b.x > t.x0 - 2 && b.x < t.x1 + 2); + b.sq = this.t; b.n++; + if (tg) { + b.last = tg.key; + this.emit('bump', tg.key); + // "boink!" beside it on the far side from the lighthouse, out of the way of the ball coming back; + // against the window's edge, under it instead + const side = b.x < g.lamp.x ? -1 : 1; + if (side < 0 ? b.x < 90 * u + 20 : b.x > g.W - 90 * u - 20) this.doodle('boink', clamp(b.x, 34 * u + 12, g.W - 34 * u - 12), b.y + pr + 14 * u + 4); + else this.doodle('boink', b.x + side * (pr + 4 * u), b.y + pr * 0.15, { align: side < 0 ? 'right' : 'left' }); + } + const h = hatGeom(this); + // the last one comes back just wide of the hat, on the sea side, clear of the gallery, and keeps going + if (b.n >= b.max) this.leg('miss', Math.min(h.bob[0] - h.pr * 2.7, g.lamp.x - g.lh.er * 1.7 - h.pr), h.bob[1] + h.pr * 0.9); + else this.leg('down', h.bob[0], h.bob[1]); + } + // back on the hat: a header, "pok", and up again + header() { + const b = this.ball, u = this.g.u, pr = this.g.lh.er * 0.66, from = b.vx > 0 ? -1 : 1; + if (!this.seen(this.g.lamp.x, this.g.lamp.y)) return this.endBall(); + this.hatKick = this.t; b.sq = this.t; + this.doodle('pok', b.x + from * (pr + 3 * u), b.y + pr * 0.3, { align: from < 0 ? 'right' : 'left', life: 0.7 }); + this.serve(); + } + // missed: the hat lunges too late, and the ball falls past the lighthouse into the sea + fall() { + const b = this.ball; + this.hatKick = this.t; + const S = b.splash = this.splashSpot(); + this.leg('fall', S.x, S.y, b.vy); + // when the sea there is under the page, "plop" where the ball was last seen + if (!S.seen) { + let last = null; + for (let i = 1; i <= 14; i++) { const p = this.legAt(b.leg, b.leg.T * i / 14); if (!this.seen(p[0], p[1])) break; last = p; } + S.edge = last; + } + } + splash() { + const b = this.ball, S = b.splash, u = this.g.u; + if (S.seen) { + this.ripples.push({ x: S.x, y: S.y, t0: this.t, a: 1 }); + this.splashes.push({ x: S.x, y: S.y, t0: this.t }); + this.doodle('plop', S.x + 12 * u, S.y - 26 * u, { life: 1.1 }); + } else if (S.edge) this.doodle('plop', S.edge[0] + 8 * u, S.edge[1] - 6 * u, { life: 1.1 }); + this.look = { x: S.edge ? S.edge[0] : S.x, y: S.edge ? S.edge[1] : S.y, until: this.t + 1.2 }; + this.ball = null; + this.regrowAt = this.t + 0.6; + } + // a spot in the sea near the lighthouse, on its sea side, that can be seen (a few DOM checks, once a game) + splashSpot() { + const g = this.g, u = g.u, L = g.lamp, near = g.horizon + (g.H - g.horizon) * 0.24, out = []; + // and clear of "the sea (wet)" while the hero's labels show + const lab = this.pres.hero > 0.01 && seaLabelSpot(this); + const onLabel = (x, y) => lab && x > lab.x - 40 * u && x < lab.x + 170 * u && y > lab.y - 50 * u && y < lab.y + 45 * u; + // not right on the horizon, where a splash is too small to read: a little way out, close to the hill + for (const fy of [0.12, 0.2, 0.3, 0.42, 0.55, 0.72, 0.88]) { + const y = g.horizon + (g.H - g.horizon) * fy; + for (const [a, z] of this.seaSpans(y)) + for (let x = Math.min(z, g.W) - 30 * u; x > Math.max(a, 0) + 18 * u; x -= 34 * u) + if (x < L.x - g.lh.wB * 1.6 && !onLabel(x, y)) out.push({ x, y, d: Math.hypot(x - g.lh.x, (y - near) * 0.8) }); + } + out.sort((p, q) => p.d - q.d); + let tries = 0; + for (const p of out) { + if (tries++ > 14) break; + if (this.seen(p.x, p.y) && this.seen(p.x, p.y - 30 * u)) return { x: p.x, y: p.y, seen: true }; + } + const p = out[0] || { x: L.x - 160 * u, y: g.H - 12 }; + return { x: p.x, y: p.y, seen: false }; + } + // the game ends quietly (editing, the page hidden, the lighthouse covered): the pom-pom is simply back + endBall() { + if (!this.ball) return; + this.ball = null; + this.regrowAt = this.reduced || document.hidden ? -9 : this.t; + } + endBore() { + const b = this.bore; + if (!b) return; + if (b.phase === 'push') this.emit('release', b.key, { caught: true }); + this.bore = null; + } + updateBore() { + const b = this.bore; + if (!b) return; + const a = this.t - b.at, g = this.g, u = g.u, L = g.lamp, er = g.lh.er; + if (b.phase === 'look' && a > 1.3) { + b.phase = 'push'; b.at = this.t; + this.emit('nudge', b.key, { dx: b.side * 14 }); + this.doodle('hehe', L.x - b.side * (er * 1.32 + 12 * u + 3), L.y + er * 0.25, { align: b.side > 0 ? 'right' : 'left', life: 2.4 }); + } else if (b.phase === 'push' && a > 6) { + b.phase = 'back'; b.at = this.t; + this.emit('release', b.key, { caught: false }); + } else if (b.phase === 'caught') { + // whistling innocently, the notes rising on the side it looks away to + if (b.notes < 3 && a > 0.45 + b.notes * 0.48) { + this.doodle('note', L.x - b.side * (er * 1.5 + (4 + b.notes * 3) * u), L.y - er * (0.9 + b.notes * 0.15), { life: 1.5, flip: b.notes % 2 }); + b.notes++; + } + if (a > 2.5) this.bore = null; + } else if (b.phase === 'back' && a > 1.6) this.bore = null; + } + // the bored trick holds the light on its widget + beamLock() { + const w = this.sweep; + if (w) { + const a = this.t - w.t0; + if (a < w.lead + w.dur + w.tail) return a < w.lead ? w.from + angDiff(w.a0, w.from) * smooth(0, 1, a / w.lead) : w.a0 + angDiff(w.a1, w.a0) * smooth(0, 1, (a - w.lead) / w.dur); + this.sweep = null; + // having lit the whole bar, it turns to look straight at you for a moment, and blinks + if (w.gaze) { this.gaze = { phi: facing(this.beam.phi), until: this.t + 3.2 }; this.blink.next = this.t + 1.1; } + } + const b = this.bore; + if (!b || !((b.phase === 'look' && this.t - b.at > 0.35) || b.phase === 'push')) return null; + return Math.atan2(b.y - this.g.lamp.y, b.x - this.g.lamp.x); + } + /* What the eye is up to: where it looks (a, m), and squint, wide, a heavier lid, a happy shut eye or a straining + squeeze */ + mood() { + const L = this.g.lamp, b = this.bore; + if (this.showing()) { + // waking up: it squeezes its eye shut, straining to light its lamp, and looks where the light is to go (the + // card's bar, then the headline), or along it once it's on + const k = this.squeeze(), aim = this.wakePath ? this.wakePath.a0 : this.nightPath?.a0, md = { shut: Math.min(1, k * 1.5), strain: k }; + if (this.lampOn() > 0.05) return { ...md, a: this.beam.a, m: 1 }; + return aim == null ? md : { ...md, a: aim, m: smooth(WAKE.open * 0.4, WAKE.open + 0.2, this.t - this.wokeAt) }; + } + if (this.ball) return { a: Math.atan2(this.ball.y - L.y, this.ball.x - L.x), m: 1 }; + if (this.look && this.t < this.look.until) return { a: Math.atan2(this.look.y - L.y, this.look.x - L.x), m: 1 }; + if (!b) return null; + const a = this.t - b.at, up = Math.atan2(b.y - L.y, b.x - L.x); + if (b.phase === 'look') return { a: up, m: smooth(0, 0.45, a), squint: smooth(0.3, 0.85, a) }; + if (b.phase === 'push') return { a: up, m: 1, squint: 1 }; + if (b.phase === 'caught') { + if (a < 0.4) return { a: up, m: 1, wide: smooth(0, 0.07, a) }; + return { a: -Math.PI / 2 - b.side * 0.9, m: 0.85, shut: 0.36 * smooth(0.4, 0.7, a), wide: 1 - smooth(0.4, 0.62, a) }; + } + return { a: up, m: 1 - smooth(0, 0.5, a), squint: 1 - smooth(0, 0.3, a), happy: a > 0.25 && a < 1.05 ? 1 : 0 }; + } + // 0 while the pom-pom is in play, growing back after it + bobbleK() { return this.ball || this.t < this.regrowAt ? 0 : pop((this.t - this.regrowAt) / 0.5); } + // a word or a note drawn by the pen for a moment; light over the sea, dark over the sky by day + doodle(kind, x, y, o = {}) { + this.doodles.push({ kind, x, y, t0: this.t, life: o.life ?? 0.95, align: o.align ?? 'center', sea: this.isSea(x, y + 4 * this.g.u), flip: o.flip ? -1 : 1, seed: Math.random() * 10, text: o.text, calm: Boolean(o.calm) }); + } + + /* ----- size and layout ----- */ + measure(first) { + const r = this.host.getBoundingClientRect(); + this.ox = r.left; this.oy = r.top; + let W = Math.max(2, Math.round(r.width)), H = Math.max(2, Math.round(r.height)); + // phones: the browser's toolbars change the height while scrolling; keep the larger one + if (!first && W === this.W && this.g.portrait && H < this.H && H > this.H * 0.7) H = this.H; + if (W === this.W && H === this.H) return false; + this.W = W; this.H = H; this.diag = Math.hypot(W, H); + const budget = 9.4e6; // device pixels for one full canvas + this.dpr = Math.min(2, window.devicePixelRatio || 1, Math.sqrt(budget / (W * H))); + this.canvas.width = this.fade.canvas.width = Math.round(W * this.dpr); this.canvas.height = this.fade.canvas.height = Math.round(H * this.dpr); + this.canvas.style.width = W + 'px'; this.canvas.style.height = H + 'px'; + this.layout(); + return true; + } + + layout() { + this.enter(this.w0); + const { W, H } = this, top = this.top, Hs = H - top; + const portrait = W < 700 || H > W * 1.1; + const u = clamp(Math.sqrt(W * H) / 1138, 0.42, 1.35); + const was = this.g ? [...this.g.clouds, this.g.sheepCloud] : []; + const g = this.g = { W, H, u, portrait, top }; + g.pw = clamp(0.55 + 0.45 * u, 0.74, 1.15); + const at = (fx, fy) => [fx * W, top + fy * Hs]; + let ridgeCtl, shoreCtl; + if (portrait) { + // a headland in the upper right, the sea to the left and below + g.horizon = top + Hs * 0.375; + ridgeCtl = [at(0.165, 0.398), at(0.2, 0.384), at(0.3, 0.366), at(0.42, 0.343), at(0.55, 0.322), at(0.65, 0.312), at(0.72, 0.31), at(0.8, 0.312), at(0.9, 0.318), at(1.06, 0.33)]; + shoreCtl = [at(0.165, 0.398), at(0.2, 0.414), at(0.3, 0.432), at(0.44, 0.466), at(0.6, 0.5), at(0.78, 0.53), at(0.92, 0.545), at(1.06, 0.552)]; + } else { + g.horizon = top + Hs * 0.6; + ridgeCtl = [[0.43, 1.04], [0.5, 0.915], [0.56, 0.81], [0.605, 0.71], [0.64, 0.636], [0.678, 0.58], [0.727, 0.549], [0.775, 0.539], [0.84, 0.547], [0.92, 0.575], [1.05, 0.615]].map(([x, y]) => at(x, y)); + shoreCtl = null; + } + g.ridge = curve(ridgeCtl, 6); + g.shore = shoreCtl ? curve(shoreCtl, 6) : null; + g.hillPoly = g.shore ? [...g.ridge, ...[...g.shore].reverse()] : [...g.ridge, [W + 40, H + 40], [g.ridge[0][0] - 40, H + 40]]; + g.beamHalf = 0.1; + const ins = 2.6 * g.pw; + g.hillClip = g.shore ? [...g.ridge.map(([x, y]) => [x, y + ins]), ...g.shore.slice().reverse().map(([x, y]) => [x, y - ins])] : [...g.ridge.map(([x, y]) => [x, y + ins]), [W + 40, H + 40], [g.ridge[0][0] - 40, H + 40]]; + + // the lighthouse: lamp, eye, gallery, tower and hat + const lx = W * (portrait ? 0.72 : 0.775); + if (portrait) { + const base = top + Hs * 0.31 + 2 * u, er = clamp(W * 0.036, 11.5, 22), lampY = top + H * 0.1; + tower(g, lx, base, base - (lampY + er * 1.02 + er * 0.62), er); + } else { + const h = Math.min(Hs * 0.27 * 1.32, W * 0.3); + tower(g, lx, this.ridgeY(lx) + 2 * u, h, h * 0.062); + } + const T = g.lh; + // the keeper's cottage, to the right of the lighthouse + const cx = portrait ? lx + T.wB * 0.5 + T.h * 0.4 : lx + T.wB * 0.5 + T.h * 0.3; + const cw = portrait ? Math.min(T.h * 0.34, W * 0.13) : T.h * 0.3; + g.cot = { x: cx, base: this.ridgeY(cx) + 3 * u, w: cw, h: cw * 0.62 }; + + const R = rng(this.o.seed); + const sky = g.horizon - top; + // clouds; the third one has legs and may be a sheep. It wanders where it can be seen. + g.clouds = (portrait ? [0.08, 0.55, 0.9] : [0.06, 0.3, 0.7, 0.93]).map((x, i) => ({ + x0: x * (W + 360 * u), y: top + sky * (portrait ? 0.08 + R() * 0.3 : 0.1 + R() * 0.38), w: (110 + R() * 80) * u * (portrait ? 0.95 : 1), + sp: 5 + R() * 8, seed: 31 * i + this.o.seed, ox: 0, oy: 0, + })); + g.sheepCloud = portrait + ? { x0: 0.1 * W, x1: 0.16 * W, y: top + H * 0.058, w: 84 * u + 22, seed: 77, ox: 0, oy: 0 } + : { x0: 0.5 * W, x1: 0.585 * W, y: top + sky * 0.13, w: 128 * u, seed: 77, ox: 0, oy: 0 }; + g.sheepCloud.x = lerp(g.sheepCloud.x0, g.sheepCloud.x1, 0.3); g.sheepCloud.dir = 1; + // animals riding a cloud move to the same cloud in the new layout + for (const o of this.objs) if (o.ride) { + const i = was.indexOf(o.ride), cl = i === was.length - 1 ? g.sheepCloud : g.clouds[i] || g.sheepCloud; + o.dx *= cl.w / (o.ride.w || cl.w); o.ride = cl; + } + const n = clamp(Math.round(W * sky / 6500), 26, 180); + g.stars = Array.from({ length: n }, () => ({ x: R() * W, y: top + 8 + R() * (sky - 24), s: 0.55 + R() * R() * 1.6, p: R() * TAU })) + .filter(s => Math.hypot(s.x - g.lamp.x, s.y - g.lamp.y) > T.er * 4); + // where animals and grass go on the hill, and the middle of the pasture + g.land = portrait ? [0.3 * W, 0.97 * W, 0.7 * W] : [0.5 * W, 0.97 * W, 0.79 * W]; + g.sun = portrait ? { x: 0.3 * W, y0: top + H * 0.115 } : { x: 0.57 * W, y0: top + sky * 0.3 }; + // (as high as the sun: lower, it would sit on the opening's title) + g.moon = portrait ? { x: 0.08 * W + 6, y1: top + H * 0.15 } : { x: 0.6 * W, y1: top + sky * 0.3 }; + + // the sea: how many rows of scallops down to the bottom on the page (see waveScallops) + g.rows = portrait ? Math.round(clamp((H - g.horizon) / 11, 26, 60)) : Math.round(18 + 14 * Math.min(1, u)); + g.tufts = this.tufts(R); + // where the page's notes sit (a guess, for placing labels and surprises) + g.note = portrait ? { x0: 0, x1: W, y0: top + Hs * 0.24, y1: H } : { x0: 0, x1: W * 0.05 + Math.min(W * 0.46, 620) + 12, y0: top, y1: H * 0.84 }; + g.key = portrait ? null : { x0: 0, x1: W * 0.05 + Math.min(W * 0.46, 562) + 12, y0: H - 160, y1: H }; + // the islands of the dive stand on this sky and sea; they're made again for the new size + this.back = g; + this.worlds = new Map([[0, this.w0]]); + this.layoutV++; + for (const o of this.objs) this.settle(o); + // the grass stays on the ground at the same depth, or goes; meals and walks start over + for (const m of this.meadow) { + const ry = this.ridgeY(m.x); + if (ry == null || ry + m.depth > Math.min(this.shoreY(m.x) - 10 * u, H - 12 * u)) m.gone = true; + else m.y = ry + m.depth; + m.by = null; + } + this.meadow = this.meadow.filter(m => !m.gone); + for (const o of this.objs) if (grazer(o) && o.act) { o.tuft = null; o.act = 'idle'; o.until = this.t + 1; } + // the bar's widgets moved with it: a game or a trick in progress ends quietly + this.endBall(); this.endBore(); + } + // grass tufts along the ridge, close together + tufts(R) { + const g = this.g, { W, H, u } = g, T = g.lh, out = []; + for (let x = g.ridge[0][0] + 14 * u; x < W; x += (13 + R() * 15) * u) { + const y = this.ridgeY(x); + if (y == null || y > H + 4 || Math.abs(x - T.x) < T.wB * 0.7 || (x > g.cot.x - g.cot.w * 0.55 && x < g.cot.x + g.cot.w * 0.5)) continue; + out.push({ x, y, l: (8 + R() * 7) * Math.max(u, 0.62), p: R() * TAU, n: 3 + ((R() * 2.2) | 0) }); + } + return out; + } + seaSpans(y) { + const spans = [], W = this.W; + let x0 = null; + for (let x = -8; x <= W + 8; x += 4) { + const sea = !this.inHill(x, y); + if (sea && x0 == null) x0 = x; + if ((!sea || x > W + 4) && x0 != null) { spans.push([x0, x - 4]); x0 = null; } + } + return spans; + } + ridgeY(x) { + const r = this.g.ridge; + if (x < r[0][0] || x > r[r.length - 1][0]) return null; + let lo = 0, hi = r.length - 1; + while (hi - lo > 1) { const m = (lo + hi) >> 1; if (r[m][0] <= x) lo = m; else hi = m; } + const [x0, y0] = r[lo], [x1, y1] = r[hi]; + return lerp(y0, y1, (x - x0) / ((x1 - x0) || 1)); + } + shoreY(x) { + const s = this.g.shore; + if (!s) return this.H + 40; + if (x < s[0][0]) return s[0][1]; + for (let i = 1; i < s.length; i++) if (s[i][0] >= x) return lerp(s[i - 1][1], s[i][1], (x - s[i - 1][0]) / ((s[i][0] - s[i - 1][0]) || 1)); + return s[s.length - 1][1]; + } + inHill(x, y) { return inPoly(this.g.hillPoly, x, y); } + isSea(x, y) { return y > this.g.horizon && !this.inHill(x, y); } + isLand(x, y) { const ry = this.ridgeY(x); return ry != null && y >= ry - 1 && y <= this.shoreY(x); } + depthScale(y) { const g = this.g; return clamp(0.8 + (y - g.horizon) / (g.H - g.horizon) * 1.0, 0.7, 1.9) * (g.portrait ? 1.15 : 1.1); } + + /* ----- where things go ----- */ + // Is a point of the scene visible, or covered by page content? A guess from the DOM. + seen(x, y) { + // (the islands of the dive are placed for when they come close, whatever covers them now) + if (this.cur !== this.w0) return true; + const els = document.elementsFromPoint?.(x + this.ox, y + this.oy); + if (!els) return true; + for (const el of els) { + if (el === this.canvas || el === this.host) return true; + if (el === document.body || el === document.documentElement) return true; + if (/^(IMG|SVG|VIDEO|BUTTON|INPUT|TEXTAREA|SELECT|CANVAS|svg)$/.test(el.tagName)) return false; + for (const pseudo of [null, '::before', '::after']) { + const cs = getComputedStyle(el, pseudo); + if (pseudo && cs.content === 'none') continue; + const bg = cs.backgroundColor.match(/[\d.]+/g); + if ((bg && bg.length === 4 ? +bg[3] > 0.15 : bg && bg.length === 3) || (cs.backgroundImage && cs.backgroundImage !== 'none')) return false; + } + } + return true; + } + spot(kind) { + const g = this.g, u = g.u, R = Math.random, T = g.lh, W = g.W, H = g.H; + const free = (x, y) => !(g.key && x > g.key.x0 && x < g.key.x1 && y > g.key.y0) && Math.abs(x - T.x) > T.wB + 26 * u; + const tries = []; + for (let i = 0; i < 40; i++) { + let x, y; + if (kind === 'gull') { + x = g.portrait ? lerp(0.08, 0.6, R()) * W : lerp(0.48, 0.68, R()) * W; + y = g.portrait ? g.top + lerp(0.04, 0.17, R()) * H : lerp(g.top + 40 * u, g.horizon - 70 * u, R()); + } else if (kind === 'duck' || kind === 'monster') { + y = lerp(g.horizon + 18 * u, H - 24 * u, R() ** (g.portrait ? 1 : 0.8)); + const sp = this.seaSpans(y), span = sp[(R() * sp.length) | 0]; + if (!span) continue; + x = lerp(Math.max(span[0], 0) + 22 + 20 * u, Math.min(span[1], W) - 10 - (kind === 'monster' ? 60 : 24) * u, R()); + if (!g.portrait && i < 30) x = lerp(Math.max(g.note.x1 + 30 * u, span[0] + 30 * u), span[1] - (kind === 'monster' ? 60 : 24) * u, R()); + } else { + x = lerp(g.land[0], g.land[1], R()); + const ry = this.ridgeY(x); + if (ry == null) continue; + y = ry + lerp(8 * u, Math.min(this.shoreY(x) - ry - 20 * u, H * 0.32), R()); + if (y > H - 20 * u) continue; + } + if (!free(x, y)) continue; + if (i < 32 && this.objs.some(o => o.dying == null && Math.abs(o.x - x) < 56 * u && Math.abs((o.ty ?? o.y) - y) < 30 * u)) continue; + tries.push({ x, y }); + if (this.seen(x, y) && this.seen(x, y - 20 * u)) return { x, y }; + } + // no land in sight (a phone, with the notes over the hill): it lands on a cloud + if (kind === 'sheep' || kind === 'cow') { + const cl = this.cloudSpot(); + if (cl) { const p = this.cloudXY(cl); return { x: p.x, y: p.y, cloud: cl }; } + } + // nothing visible: drop it where it belongs anyway, falling from the sky + const p = tries[0] || { x: W * 0.8, y: H * 0.8 }; + return { ...p, fromSky: true }; + } + // a cloud that shows, the one with the fewest riders first, for a sheep or a cow with no land in sight + cloudSpot() { + const g = this.g, u = g.u, R = Math.random, T = g.lh; + const riders = cl => this.objs.filter(o => o.ride === cl && o.dying == null).length + R() * 0.5; + for (const cl of [...g.clouds, g.sheepCloud].sort((a, b) => riders(a) - riders(b))) { + const p = this.cloudXY(cl); + if (p.x > 40 * u && p.x < g.W - 40 * u && Math.hypot(p.x - g.lamp.x, p.y - g.lamp.y) > T.er * 5 && this.seen(p.x, p.y - cl.w * 0.2)) return cl; + } + return null; + } + + spawn(x, y, kind, settled, fromSky) { + const g = this.g, u = g.u, R = Math.random; + const land = this.isLand(x, y), sea = this.isSea(x, y); + const o = { x, y, vx: 0, vy: 0, t0: this.t, flip: R() < 0.5, seed: (R() * 1e4) | 0, lit: 0, kind }; + if (!o.kind) { + if (land) o.kind = R() < 0.6 ? 'sheep' : 'cow'; + else if (sea) o.kind = R() < 0.55 ? 'duck' : 'monster'; + else if (y < lerp(g.top, g.horizon, 0.55) && R() < 0.45) o.kind = 'gull'; + else o.kind = this.ridgeY(x) != null && y < this.shoreY(x) && x > (g.shore ? 0 : this.hillLeftX(g.horizon) - 20 * u) ? (R() < 0.6 ? 'sheep' : 'cow') : 'duck'; // from the sky, only ducks: monsters surface where the water is clicked + } + const still = settled || this.reduced; + if (o.kind === 'gull') { + o.state = 'fly'; o.vx = (o.flip ? -1 : 1) * (60 + R() * 60) * u; o.vy = -(6 + R() * 18) * u; o.sc = 1.15; + if (this.reduced) { o.vx = 0; o.vy = 0; o.state = 'hover'; } + } else if (o.kind === 'sheep' || o.kind === 'cow') { + let ry = this.ridgeY(x); + if (ry == null) { o.x = x = clamp(x, g.ridge[0][0] + 20 * u, Math.min(g.W, g.ridge.at(-1)[0]) - 14 * u); ry = this.ridgeY(x); } + const room = this.shoreY(x) - ry - 14 * u; + o.depth = land ? clamp(y - ry, 3 * u, Math.min(room, g.H - ry - 24 * u)) : clamp((6 + R() * 40) * u, 3 * u, room); + o.ty = ry + o.depth; + if (still || land && !fromSky) { o.state = 'land'; o.y = o.ty; o.land = still ? -9 : this.t; } + else { o.state = 'fall'; if (fromSky) o.y = Math.min(o.ty - 120 * u, g.top - 20); } + o.sc = this.depthScale(o.ty); + } else { + o.ty = sea ? y : g.horizon + (g.H - g.horizon) * (0.16 + R() * 0.55); + if (!sea) { const sp = this.seaSpans(o.ty); const s = sp.find(q => q[0] <= x && x <= q[1]) || sp[0]; if (s) o.x = clamp(x, s[0] + 24 * u, s[1] - 30 * u); } + if (o.kind === 'monster' || still || sea && !fromSky) { o.y = o.ty; o.state = o.kind === 'monster' ? 'swim' : 'float'; if (!this.reduced && !settled) this.ripples.push({ x: o.x, y: o.ty, t0: this.t, a: 0.8 }); } + else { o.state = 'fall'; if (fromSky) o.y = Math.min(o.ty - 120 * u, g.top - 20); } + o.sc = this.depthScale(o.ty); + } + this.objs.push(o); + const live = this.objs.filter(q => q.dying == null); + if (live.length > MAX_OBJS) live[0].dying = this.t; + return o; + } + ride(kind, cl, settled) { + const g = this.g, R = Math.random, p = this.cloudXY(cl); + const o = { kind, ride: cl, dx: (R() - 0.5) * cl.w * 0.3, x: p.x, y: 0, vx: 0, vy: 0, t0: this.t, flip: R() < 0.5, seed: (R() * 1e4) | 0, lit: 0, sc: 0.85 * (g.portrait ? 1.1 : 1) }; + o.x = p.x + o.dx; o.ty = this.rideY(o); + if (this.reduced || settled) { o.state = 'ride'; o.y = o.ty; o.land = -9; } else { o.state = 'fall'; o.y = g.top - 30; } + this.objs.push(o); + const live = this.objs.filter(q => q.dying == null); + if (live.length > MAX_OBJS) live[0].dying = this.t; + return o; + } + rideY(o) { const p = this.cloudXY(o.ride); return p.y - o.ride.w * 0.17; } + // reduced motion switched on: whatever is moving stops where it is + still() { + for (const o of this.objs) { + if (o.state === 'leap') this.endLeap(o); + if (o.state === 'fly') { o.state = 'hover'; o.vx = o.vy = 0; } + else if (o.state === 'fall') { o.y = o.ty; o.vy = 0; o.state = o.ride ? 'ride' : o.kind === 'duck' ? 'float' : 'land'; } + o.land = -9; + if (o.dying != null) o.dead = true; + // no one eats: heads up, meals over + if (grazer(o)) { this.letGo(o); o.act = 'idle'; o.head = 0; o.vx = o.vd = 0; o.joy = null; } + } + this.objs = this.objs.filter(o => !o.dead); + // the grass stands still: grown, with whatever is left of it + for (const m of this.meadow) { m.t0 = -99; m.blades = m.blades.filter(b => b.cut == null); m.left = m.blades.length; } + this.meadow = this.meadow.filter(m => m.left > 0); + this.box.hop = this.box.peek = -9; + this.ripples = []; + this.endBall(); this.endBore(); + this.wakePath = this.gaze = this.visit = null; this.waking = false; + this.regrowAt = this.hatKick = -9; + this.doodles = []; this.splashes = []; this.look = null; + } + settle(o) { + // after a resize, put land animals back on the ground and sea animals back in the water + if (o.ride) return; + if (o.kind === 'sheep' || o.kind === 'cow') { const ry = this.ridgeY(clamp(o.x, this.g.ridge[0][0], this.W - 10)); if (ry != null) { o.ty = ry + Math.min(o.depth, this.shoreY(o.x) - ry - 14); if (o.state !== 'fall') o.y = o.ty; } } + else if (o.kind !== 'gull' && !this.isSea(o.x, o.ty)) o.dying ??= this.t; + } + hillLeftX(y) { + const r = this.g.ridge; + for (let i = 1; i < r.length; i++) if (r[i][1] <= y) { const [x0, y0] = r[i - 1], [x1, y1] = r[i]; return lerp(x0, x1, (y - y0) / ((y1 - y0) || 1)); } + return this.W; + } + + /* ----- time ----- */ + wake() { + this.awake = Math.max(this.awake, performance.now() + (this.reduced ? 300 : IDLE_AFTER)); + this.kick = 3; // at least a few frames, however late the first one comes + if (this.timer) { clearTimeout(this.timer); this.timer = 0; } + this.schedule(); + } + sleep() { cancelAnimationFrame(this.raf); clearTimeout(this.timer); this.raf = this.timer = 0; } + schedule() { + if (this.raf || this.timer || this.dead || document.hidden) return; + // frozen: a frame only when one is asked for (a resize, a new theme) or while the hour changes, none in between + if (this.frozen) { if (this.kick > 0 || this.d !== (this.dEff ?? this.dTarget)) this.raf = requestAnimationFrame(this.frame); return; } + const active = this.kick > 0 || performance.now() < this.awake || this.busy(); + if (active) this.raf = requestAnimationFrame(this.frame); + else if (!this.reduced) this.timer = setTimeout(() => { this.timer = 0; this.raf = requestAnimationFrame(this.frame); }, 1000 / IDLE_FPS - 6); + // reduced motion and nothing moving: the scene sleeps until the next call or input + } + busy() { + if (this.swap || this.wipe || this.sweep || this.wakePath || this.gaze || this.visit || this.t - this.knocks.at < 0.5 || this.showing() || this.t - this.wokeAt < WAKE.open + 0.2 || this.t - this.labelsAt < 0.6) return true; + if (Math.abs(this.box.lid - (this.boxEmpty() ? 1 : 0)) > 0.01) return true; + if (this.d !== (this.dEff ?? this.dTarget) || (this.state === 'install') !== (this.pres.install > 0.999)) return true; + for (const k of STATES) { const p = this.pres[k]; if (p > 0.001 && p < 0.999) return true; } + if (this.ripples.length) return true; + // (the grazing and its "nom"s are slow: they carry on at the idle rate) + if (this.ball || this.bore || this.doodles.some(d => !d.calm) || this.splashes.length || this.t - this.hatKick < 0.7 || this.t - this.regrowAt < 0.9 || this.t - this.box.hop < 0.8 || this.t - this.box.wob < 0.5) return true; + for (const o of this.objs) if (o.state === 'fall' || o.state === 'leap' || (o.state === 'fly' && !this.reduced) || o.dying != null || (o.land > 0 && this.t - o.land < 0.4)) return true; + return this.beam.steer > 0.01 && this.beam.steer < 0.99 && !this.reduced; + } + frame(now) { + this.raf = 0; + if (this.dead || document.hidden) return; + this.kick--; + const dt = this.last == null ? 1 / 60 : Math.min(0.1, Math.max(0, (now - this.last) / 1000)); + this.last = now; + // frozen, only the very first frame moves anything (it puts everyone in their place); after it only the hour does + if (!this.frozen || this.t === 0) this.update(dt); + else this.stepDusk(dt); + const t0 = performance.now(); + this.render(); + this.times.push(performance.now() - t0); + if (this.times.length > 240) this.times.shift(); + this.onFrame?.(this); + this.schedule(); + } + // the hour: toward the one asked for, quickly, or evenly over the time given (see setDusk) + stepDusk(dt) { + const rm = this.reduced, tw = this.dTween; + // the install state brings the night with it + const dTarget = this.dEff = Math.max(this.dTarget, this.pres.install > 0 ? lerp(this.dTarget, 1, this.pres.install) : 0); + if (tw && !rm) { tw.a += dt; const k = smooth(0, 1, tw.a / tw.dur); this.d = lerp(tw.from, dTarget, k); if (k >= 1) this.dTween = null; } + else this.d = rm ? dTarget : this.d + (dTarget - this.d) * Math.min(1, dt * 2.4); + if (Math.abs(dTarget - this.d) < 0.0005) this.d = dTarget; + } + update(dt) { + const g = this.g, u = g.u, rm = this.reduced; + this.t += dt; + for (const k of STATES) { + const want = k === this.state ? 1 : 0; + this.pres[k] = rm ? want : clamp(this.pres[k] + Math.sign(want - this.pres[k]) * dt * 1.8); + } + this.stepDusk(dt); + if (this.swap && this.t - this.swap.t0 > SWAP) this.swap = null; + // out at sea, the page goes over to the theme of the island you're heading to as the one before it goes past (the + // far island's as soon as the dive begins), and back on the page to the visitor's: the new theme opens out in a + // circle from the middle of the horizon, and the bar goes over as the circle reaches it. When that changes again + // before the circle covers the screen, it's over at once and the next one starts + const to = this.dive ? this.world(Math.max(1, Math.floor(this.dive + 0.95))).g.theme : this.theme, wp = this.wipe; + if (wp && (to !== wp.to || this.t - wp.t0 > WIPE)) { + if (!wp.bar) this.emit('theme', wp.to); + this.show(wp.to); + this.wipe = null; + } + if (!this.wipe && to !== this.shows) this.wipe = { to, t0: this.t }; + if (this.wipe) { + const w = this.wipe, B = this.back, y = B.horizon; + w.r = Math.hypot(B.W / 2, Math.max(y, B.H - y)) * smooth(0, WIPE, this.t - w.t0); + if (!w.bar && w.r > y - B.top / 2) { w.bar = true; this.emit('theme', w.to); } + } + this.vel.x *= Math.exp(-dt * 2.5); this.vel.y *= Math.exp(-dt * 2.5); + this.windAmb = rm ? 0.2 : 0.28 + 0.22 * noise(this.t * 0.13 + 3); + this.wind = lerp(this.wind, clamp(this.vel.x / 1300, -1.5, 1.5), Math.min(1, dt * 1.8)); + this.windTotal = this.windAmb + this.wind; + if (!rm) this.drift += dt * (1 + this.windTotal * 2.4); + // clouds lean away from the pointer + for (const cl of [...g.clouds, g.sheepCloud]) { + let tx = 0, ty = 0; + if (this.cursor.on && !rm) { + const p = this.cloudXY(cl, true), dx = p.x - this.cursor.x, dy = p.y - this.cursor.y, r = 170 * u, dd = Math.hypot(dx, dy); + if (dd < r) { const k = (1 - dd / r) ** 2 * 48 * u / (dd || 1); tx = dx * k; ty = dy * k; } + } + const k = rm ? 1 : Math.min(1, dt * 3); + cl.ox = lerp(cl.ox, tx, k); cl.oy = lerp(cl.oy, ty, k); + } + // the cloud with legs wanders to and fro, against the wind if it likes + const sc = g.sheepCloud; + if (!rm) { + sc.x += sc.dir * 7 * u * dt; + if (sc.x > sc.x1) sc.dir = -1; else if (sc.x < sc.x0) sc.dir = 1; + } + // the eye blinks now and then + if (!rm && this.t > this.blink.next) { this.blink.at = this.t; this.blink.next = this.t + 3 + Math.random() * 5; } + // its lamp lit for good, after all that straining: the hat jumps + if (this.waking && !this.popped && this.riseAt != null && this.t - this.riseAt >= WAKE.on) { this.popped = true; this.hatKick = this.t; } + for (const w of this.shown()) { this.enter(w); this.updateIsland(dt); } + this.enter(this.focus()); + } + // what moves on an island: its light, the ripples round it, its animals, grass and box, its games with the bar + updateIsland(dt) { + const g = this.g, u = g.u; + // at the cottage's door: the keeper wonders who knocked (or nobody comes), and it's over + const v = this.visit; + if (v) { + const a = this.t - v.t0, k = g.cot, d = k.door; + if (!v.huh && d && a >= (v.away ? 0.9 : VISIT.huh)) { + // (by the cottage, where the knock was: over its roof, the pen wouldn't show) + v.huh = true; + if (v.away) this.doodle('huh', d.x + d.w / 2, d.y - 8 * u, { text: '…', life: 1.1 }); + else this.doodle('huh', k.x + k.w * 0.66, d.y + d.h * 0.15, { text: '?', align: 'left', life: 1 }); + } + if (a > (v.away ? VISIT.away : VISIT.end)) this.visit = null; + } + this.updateBeam(dt); + this.ripples = this.ripples.filter(r => this.t - r.t0 < 2.2); + if (!this.flocked && !this.held) this.flock(); + this.updateObjs(dt); + this.updateMeadow(); + this.updateBox(dt); + this.updateBall(dt); + this.updateBore(); + this.doodles = this.doodles.filter(d => this.t - d.t0 < d.life); + this.splashes = this.splashes.filter(p => this.t - p.t0 < 0.7); + } + updateBeam(dt) { + const b = this.beam, L = this.g.lamp, now = performance.now() / 1000, rm = this.reduced, wp = this.wakePath, sw = this.swap; + if (sw && this.cur === this.w0) { + // a new style coming in: the page's light turns once round from where it was, all the way out (see setTheme) + b.a = sw.a = sw.a0 + TAU * smooth(0, SWAP, this.t - sw.t0); b.len = 1; b.flash = 0; + return; + } + if (wp && (this.ball || this.t - wp.t0 > WAKE.end)) { + this.wakePath = null; + // done reading: on to the bar + if (!this.ball) this.sweepBar(1.3); + } + if (this.gaze && (this.ball || this.sweep || this.wakePath || this.t > this.gaze.until)) { this.gaze = null; this.restAt = this.t; } + // (asked first: when the sweep ends here, the look at you starts this very frame, before the pointer can pull + // the light) + const lock = this.wakePath || this.gaze || this.showing() ? null : this.beamLock(); + if (this.wakePath) { + // the day comes (see rising): its weak light goes out on the title, and its light for good comes on at the + // headline's first word and reads across to the last + const w = this.t - wp.t0; + b.steer = 1; + if (w >= WAKE.out) b.sa = wp.a0 + angDiff(wp.a1, wp.a0) * smooth(WAKE.read, WAKE.read + WAKE.dur, w); + } else if (this.showing()) { + // at night (see lightBar): its weak light comes on at one end of the card's bar and crosses it, a little unsteady + const np = this.nightPath; + b.steer = 1; + if (np) b.sa = np.a0 + angDiff(np.a1, np.a0) * this.scanK() + (rm ? 0 : 0.02 * Math.sin(this.t * 2.1)); + } else if (this.gaze) { + // looking at you: the light comes round to face you and stays there a moment (the pointer can wait) + b.phi = this.gaze.phi; + b.steer = lerp(b.steer, 0, Math.min(1, dt * 3)); + } else { + const quiet = this.ptr.kind === 'touch' ? 3.2 : 5; + const steering = lock != null || (this.ptr.on && now - this.ptr.last < quiet); + b.steer = rm ? (steering || b.steer > 0.5 ? 1 : 0) : lerp(b.steer, steering ? 1 : 0, Math.min(1, dt * (steering ? 4 : 0.7))); + if (!rm) b.phi += dt * TAU / 15; + if (lock != null) b.sa += angDiff(lock, b.sa) * Math.min(1, dt * (this.sweep ? 9 : 3.2)); + else if (this.ptr.on && steering) { + const want = this.clampAim(Math.atan2(this.ptr.y - L.y, this.ptr.x - L.x)); + b.sa = rm ? want : b.sa + angDiff(want, b.sa) * Math.min(1, dt * 5); + } + } + const c = Math.cos(b.phi), sn = Math.sin(b.phi); + const vx = lerp(c, Math.cos(b.sa), b.steer), vy = lerp(0.03 * Math.abs(c), Math.sin(b.sa), b.steer); + b.a = Math.atan2(vy, vx); + b.len = clamp(Math.hypot(vx, vy)) * this.lampOn() * this.reach(); + b.flash = Math.pow(Math.max(0, sn), 14) * (1 - b.steer) * this.lampOn(); + } + updateObjs(dt) { + const g = this.g, u = g.u, G = 1500 * u, rm = this.reduced; + for (const o of this.objs) { + if (o.ride) { + // riding a cloud: follow it, landing first if still falling + const p = this.cloudXY(o.ride); + o.x = p.x + o.dx; o.ty = this.rideY(o); + if (o.state === 'fall') { o.vy += G * dt; o.y += o.vy * dt; if (o.y >= o.ty) { o.y = o.ty; o.state = 'ride'; o.land = this.t; } } + else o.y = o.ty; + o.lit = lerp(o.lit, this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy), rm ? 1 : Math.min(1, dt * 8)); + if (o.dying != null && (rm || this.t - o.dying > 0.7)) o.dead = true; + continue; + } + if (o.state === 'leap') { + // out of the box onto the grass, or back in: a hop along an arc (out, it rises before it goes sideways) + const L = o.leap, f = clamp((this.t - L.t0) / L.dur); + o.x = lerp(L.x0, L.x1, L.in ? smooth(0, 0.85, f) : smooth(0.15, 1, f)); + o.y = lerp(L.y0, L.y1, f) - 4 * L.h * f * (1 - f); + if (f >= 1) this.endLeap(o); + } else if (o.state === 'fall') { + o.vy += G * dt; o.y += o.vy * dt; + if (o.y >= o.ty) { + o.y = o.ty; o.vy = 0; o.land = this.t; + o.state = o.kind === 'duck' ? 'float' : 'land'; + if (o.kind === 'duck') this.ripples.push({ x: o.x, y: o.ty, t0: this.t, a: 1 }); + } + } else if (rm) { + // reduced motion: everything holds still where it appeared + } else if (o.state === 'land') { + this.live(o, dt); + } else if (o.state === 'float') { + o.x += this.windTotal * 6 * u * dt; + o.y = o.ty + Math.sin(this.t * 2.2 + o.seed) * 1.8 * u; + if (!this.isSea(o.x + 16 * u, o.ty - 1) || o.x > g.W - 10) o.x -= this.windTotal * 6 * u * dt + 0.5; + } else if (o.state === 'swim') { + o.x += (o.flip ? -1 : 1) * 16 * u * o.sc * dt; + if (!this.isSea(o.x + (o.flip ? -40 : 64) * u * o.sc, o.ty - 1) || o.x < 50 * u || o.x > g.W - 60 * u) o.flip = !o.flip; + } else if (o.state === 'fly') { + if (o.calm > 0) { o.calm -= dt; o.x += o.vx * 0.15 * dt; o.y += Math.sin(this.t * 2 + o.seed) * 10 * u * dt; } + else { o.x += o.vx * dt; o.y += (o.vy + Math.sin(this.t * 2 + o.seed) * 14 * u) * dt; o.vy *= 1 - dt * 0.4; } + if (o.x < -100 || o.x > g.W + 100 || o.y < -100) o.dead = true; + } + o.lit = rm ? this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy) : lerp(o.lit, this.inBeam(o.x + this.ox, o.y - 12 * u * o.sc + this.oy), Math.min(1, dt * 8)); + if (o.dying != null && (rm || this.t - o.dying > 0.7)) o.dead = true; + } + for (const o of this.objs) if (o.dead || o.dying != null) this.letGo(o); + this.objs = this.objs.filter(o => !o.dead); + } + + /* ----- a lived-in hill ----- + A small flock is there from the start, settled where it's seen. Tufts of grass sprout here and there on + the visible hill; a sheep or a cow that isn't busy walks over to the nearest free one (across the slope as + much as along it), lowers its head and eats it blade by blade, looks pleased, and goes back to idling and + wandering. Sheep still run from the pointer, mid-meal or not. All of it is slow, so it runs at the idle rate. */ + flock() { + this.flocked = true; + const g = this.g, u = g.u, R = Math.random; + if (g.portrait) { + // phones: one or two, on whatever land or cloud shows in the first screen + for (const kind of ['sheep', R() < 0.4 ? 'cow' : 'sheep']) { + const p = this.landSpot({ gap: 50 * u }); + if (p) this.spawn(p.x, p.y, kind, true); + else { const cl = this.cloudSpot(); if (cl && !this.objs.some(o => o.ride === cl)) this.ride(kind, cl, true); } + } + } else { + for (const kind of g.W >= 1200 ? ['sheep', 'cow', 'sheep', 'sheep'] : ['sheep', 'cow', 'sheep']) { + const p = this.landSpot({ gap: 64 * u, spread: true, check: 4, snug: true }); + if (p) this.spawn(p.x, p.y, kind, true); + } + const d = this.seaSpot(); + if (d) this.spawn(d.x, d.y, 'duck', true); + } + // grass already there: one a short walk from somebody (the first meal comes soon), the rest spread out + const first = this.objs.find(grazer); + for (let i = 0, n = (g.portrait ? 5 : 9) + (R() < 0.5 ? 1 : 0); i < n; i++) this.sprout(true, i === 0 && first && !this.reduced ? first : null); + this.sproutAt = this.t + 4 + R() * 4; + // not everyone is hungry at once: the one nearest some grass first, soon, and the others in turn + const near = o => Math.min(1e9, ...this.meadow.map(m => Math.hypot(m.x - o.x, (m.y - o.y) * 1.5))); + this.objs.filter(grazer).sort((a, b) => near(a) - near(b)).forEach((o, k) => { + o.hungry = this.t + (k ? 6 + k * (6 + R() * 5) : 0); o.act = 'idle'; o.until = this.t + (k ? 1 + R() * 3 : 1); o.head = 0; o.vd = 0; + }); + } + /* A visible spot on the hill for an animal or a tuft, clear of the lighthouse, the cottage, the key and the + section's props, at least `gap` from the others (the farthest of a few when `spread`), or near `near`. + A few DOM checks at most; null when none shows. */ + landSpot({ gap = 0, spread = false, near = null, reach = 0, min = 0, check = 3, snug = false, clear = false } = {}) { + const g = this.g, u = g.u, R = Math.random, W = g.W, H = g.H, out = []; + const others = [...this.objs.filter(o => grazer(o) && o.dying == null && o !== near), ...this.meadow]; + for (let i = 0; i < 40; i++) { + const x = near ? near.x + (R() * 2 - 1) * reach : lerp(g.land[0], g.land[1], R()); + const ry = this.ridgeY(x); + if (ry == null || x > W - 22 * u) continue; + const room = Math.min(this.shoreY(x) - 16 * u, H - 24 * u, ry + H * 0.36) - ry; + if (room < 12 * u) continue; + const y = near ? clamp(near.y + (R() * 2 - 1) * reach * 0.45, ry + 6 * u, ry + room) : ry + lerp(8 * u, room, R() ** 0.85); + if (near && Math.hypot(x - near.x, y - near.y) < min) continue; + // snug: well inside the hill, not on its steep edge over the sea or down at the window's bottom + if (snug && (y > H - 84 * u || !this.isLand(x - 46 * u, y) || !this.isLand(x - 46 * u, y + 16 * u))) continue; + if (!this.open(x, y)) continue; + // clear: not hidden behind an animal's body, nor under its feet + if (clear && others.some(o => !o.blades && Math.abs(o.x - x) < 32 * u * o.sc && y > o.y - 36 * u * o.sc && y < o.y + 12 * u)) continue; + let d = 1e9; + for (const o of others) d = Math.min(d, Math.hypot(o.x - x, (o.y - y) * 1.6)); + if (d < gap) continue; + out.push({ x, y, d }); + } + // spread out, but not into the corners: far enough from the others, then near the middle of the pasture + if (spread) { const mid = g.land[2], sc = p => Math.min(p.d, 170 * u) - Math.abs(p.x - mid) * 0.3; out.sort((a, b) => sc(b) - sc(a)); } + // seen: its middle, and its ends too unless it's only a walk's goal + for (let i = 0; i < Math.min(check, out.length); i++) { + const p = out[i]; + if (this.seen(p.x, p.y - 6 * u) && this.seen(p.x, p.y - 24 * u) && (near || (this.seen(p.x - 20 * u, p.y - 8 * u) && this.seen(p.x + 20 * u, p.y - 8 * u)))) return p; + } + return null; + } + // nothing of the scene's own in the way: the lighthouse, the cottage, the key, the huts, the desk + open(x, y) { + const g = this.g, u = g.u, T = g.lh, k = g.cot, bx = this.box; + if (bx.gx != null && Math.abs(x - bx.gx) < 44 * u && y > bx.gy - 46 * u && y < bx.gy + 18 * u) return false; + if (g.key && x > g.key.x0 && x < g.key.x1 && y > g.key.y0 - 30 * u) return false; + if (Math.abs(x - T.x) < T.wB * 0.9 + 20 * u && y < T.base + 42 * u) return false; + if (Math.abs(x - k.x) < k.w * 0.8 + 18 * u && y < k.base + 32 * u) return false; + if (this.state === 'tiling' || this.pres.tiling > 0.01) for (const q of hutSpots(this)) if (Math.abs(x - q.x) < 42 * u && y > q.y - 74 * u && y < q.y + 24 * u) return false; + if (this.state === 'widgets' || this.pres.widgets > 0.01) { const d = deskSpot(this); if (Math.abs(x - d.x) < 66 * u && y > d.y - 110 * u && y < d.y + 24 * u) return false; } + return true; + } + // a visible spot on the sea for the duck, clear of "the sea (wet)" + seaSpot() { + const u = this.g.u, lab = seaLabelSpot(this); + for (let i = 0; i < 4; i++) { + const p = this.spot('duck'); + if (p.fromSky) return null; + if (!lab || !(p.x > lab.x - 46 * u && p.x < lab.x + 160 * u && p.y > lab.y - 34 * u && p.y < lab.y + 54 * u)) return p; + } + return null; + } + // a tuft of grass where it's seen (near `near` if it can); `grown` for the ones already there when the page opens + sprout(grown, near) { + const u = this.g.u, R = Math.random; + const p = (near && this.landSpot({ near, reach: 110 * u, min: 55 * u, gap: 30 * u, snug: true, clear: true, check: 3 })) + || this.landSpot({ gap: 30 * u, snug: true, clear: true, spread: grown }); + if (!p) return null; + const sc = this.depthScale(p.y) * 0.85, n = 6 + ((R() * 4) | 0), blades = []; + for (let i = 0; i < n; i++) { + const f = i / (n - 1) - 0.5; + blades.push({ + dx: (f * 15 + (R() - 0.5) * 1.8) * u * sc, a: f * 1.05 + (R() - 0.5) * 0.22, flex: 0.7 + R() * 0.5, + l: (14 + R() * 7) * (1 - Math.abs(f) * 0.5) * u * sc, d: Math.abs(f) * 1.1 + R() * 0.3, cut: null, + }); + } + const m = { x: p.x, y: p.y, depth: p.y - this.ridgeY(p.x), sc, blades, left: n, lastCut: -9, by: null, t0: grown || this.reduced ? -99 : this.t, ph: R() * TAU }; + this.meadow.push(m); + return m; + } + updateMeadow() { + if (!this.flocked) return; + const t = this.t, R = Math.random; + // the last blade gone: the tuft goes, and whoever ate it looks pleased + for (const m of this.meadow) { + if (m.left > 0 || t - m.lastCut < 0.45) continue; + m.gone = true; + const o = m.by; + if (o) { o.tuft = null; o.act = 'happy'; o.joy = t; o.until = t + 1.3; o.hungry = t + 30 + R() * 30; } + } + this.meadow = this.meadow.filter(m => !m.gone); + // a new one now and then, only while the page shows (the scene's time stands still while it's hidden) + if (this.reduced || t < this.sproutAt) return; + const max = this.g.portrait ? MEADOW_PHONE : MEADOW_MAX, m = this.meadow.length < max ? this.sprout(false) : null; + this.sproutAt = t + (m || this.meadow.length >= max ? lerp(SPROUT_MIN, SPROUT_MAX, R()) : 3); + } + letGo(o) { if (o.tuft) { if (o.tuft.by === o) o.tuft.by = null; o.tuft = null; } } + // one animal's day: idle, wander, walk to a tuft and eat it, look pleased; run from the pointer (sheep) + live(o, dt) { + const g = this.g, u = g.u, t = this.t, R = Math.random, cow = o.kind === 'cow'; + if (o.act == null) { o.act = 'idle'; o.until = t + 0.5 + R() * 2.5; o.hungry ??= t + 3 + R() * 10; o.head = 0; o.vd = 0; o.bites = 0; } + o.vd ??= 0; + const dx = o.x - this.ptr.x, dy = o.y - this.ptr.y; + const scared = this.ptr.on && !cow && o.dying == null && o.act !== 'home' && dx * dx + dy * dy < (110 * u) ** 2; + let wx = 0, wd = 0, head = 0; + if (scared) { + this.letGo(o); + o.act = 'flee'; o.until = t + 0.5 + R() * 0.8; + wx = Math.sign(dx || 1) * 70 * u; wd = clamp(dy / (110 * u), -1, 1) * 26 * u; + } else { + if ((o.act === 'flee' || o.act === 'happy') && t > o.until) { o.act = 'idle'; o.until = t + 1.5 + R() * 3.5; } + if (o.act === 'idle' && t > o.until) this.decide(o); + if (o.act === 'home') { + // called home: to the box's side, then a hop in (from wherever it got to, if that takes too long) + const h = this.homeSpot(o); + if (!h || t > o.until) return this.hopIn(o); + o.tx = h.x; o.td = h.d; + } + if (o.act === 'walk' || o.act === 'go' || o.act === 'home') { + const ex = o.tx - o.x, ed = o.td - o.depth, dist = Math.hypot(ex, ed); + if (o.act === 'home' && dist < 3 * u) return this.hopIn(o); + if (o.act === 'go' && dist < 2.5 * u && o.tuft) { o.act = 'eat'; o.bite = t + 0.8; o.flip = o.tuft.x < o.x; } + else if (o.act !== 'home' && (dist < 2.5 * u || t > o.until || (o.act === 'go' && !o.tuft))) { this.letGo(o); o.act = 'idle'; o.until = t + 2 + R() * 4; } + else { + const v = (o.act === 'home' ? 30 : o.act === 'go' ? 19 : 13) * u * (cow ? 0.65 : 1) * Math.min(1, dist / (6 * u)); + wx = ex / dist * v; wd = ed / dist * v; + } + } + if (o.act === 'eat') { + const m = o.tuft; + if (!m) { o.act = 'idle'; o.until = t + 1 + R() * 2; } + else { + head = 1; + o.flip = m.x < o.x; + if (m.left > 0 && t > o.bite && o.head > 0.85) this.bite(o, m); + } + } + } + o.head = lerp(o.head || 0, head, Math.min(1, dt * 4)); + // walk, in x and in depth (across the slope), as far as the hill allows + const k = Math.min(1, dt * 3); + o.vx = lerp(o.vx, wx, k); o.vd = lerp(o.vd, wd, k); + if (o.act !== 'eat' && Math.abs(o.vx) > 2.5 * u) o.flip = o.vx < 0; + const roomAt = (x, ry) => Math.min(g.H - 10 * u, this.shoreY(x) - 12 * u) - ry; + const nx = o.x + o.vx * dt, ny = this.ridgeY(nx); + if (ny == null || roomAt(nx, ny) < 3 * u || nx > g.W - 12 * u || (Math.abs(nx - g.lh.x) < g.lh.wB * 0.6 && o.depth < 30 * u)) o.vx = 0; + else o.x = nx; + const ry = this.ridgeY(o.x); + if (ry == null) return; + o.depth = clamp(o.depth + o.vd * dt, 3 * u, Math.max(3 * u, roomAt(o.x, ry))); + o.y = o.ty = ry + o.depth; + o.sc = this.depthScale(o.y); + } + // what next: grass if hungry and there's some free nearby, otherwise a short walk or a rest + decide(o) { + const t = this.t, R = Math.random, u = this.g.u; + // standing where one of the section's little scenes now shows: out of its way first + if (!this.open(o.x, o.y - 6 * u)) { + const p = this.landSpot({ near: o, reach: 170 * u, min: 40 * u, gap: 34 * u, check: 1 }); + if (p) { o.act = 'walk'; o.tx = p.x; o.td = p.y - this.ridgeY(p.x); o.until = t + 14; return; } + } + if (t > o.hungry && this.feed(o)) return; + if (R() < 0.5) { + const p = this.landSpot({ near: o, reach: 100 * u, min: 30 * u, gap: 34 * u, check: 1 }); + if (p) { o.act = 'walk'; o.tx = p.x; o.td = p.y - this.ridgeY(p.x); o.until = t + 14; return; } + } + o.act = 'idle'; o.until = t + 2.5 + R() * 4.5; + if (t > o.hungry) o.hungry = t + 3 + R() * 4; // nothing to eat in reach: look again in a bit + } + // the nearest free tuft in reach (grown, nobody else at it): walk over to stand with the mouth at it + feed(o) { + const g = this.g, u = g.u, reach = (o.kind === 'cow' ? 21 : 19) * u; + // they graze in turns: a few at the grass at once, at most, so the hill stays calm + const herd = this.objs.filter(q => grazer(q) && q.dying == null), busy = herd.filter(q => q.tuft).length; + if (busy >= Math.min(3, Math.max(1, Math.round(herd.length * 0.4)))) return false; + let best = null, bd = 320 * u; + for (const m of this.meadow) { + if (m.by || m.left <= 0 || this.t - m.t0 < 2.2) continue; + const d = Math.hypot(m.x - o.x, (m.y - o.y) * 1.5); + if (d < bd) { bd = d; best = m; } + } + if (!best) return false; + const sc = this.depthScale(best.y); + for (const f of best.x >= o.x ? [1, -1] : [-1, 1]) { + const x = best.x - f * reach * sc, ry = this.ridgeY(x); + if (ry == null || x > g.W - 14 * u) continue; + const d = best.y - ry, room = Math.min(g.H - 10 * u, this.shoreY(x) - 12 * u) - ry; + if (d < 3 * u || d > room || (Math.abs(x - g.lh.x) < g.lh.wB * 0.6 && d < 30 * u)) continue; + // not where another animal already stands + if (this.objs.some(q => q !== o && grazer(q) && Math.abs(q.x - x) < 30 * u * sc && Math.abs(q.y - best.y) < 14 * u * sc)) continue; + best.by = o; o.tuft = best; o.act = 'go'; o.tx = x; o.td = d; o.until = this.t + 45; + return true; + } + return false; + } + // a blade gone, the one nearest the mouth; every other bite, a "nom" + bite(o, m) { + const t = this.t, u = this.g.u, f = o.flip ? -1 : 1, mx = o.x + f * 20 * u * o.sc; + let pick = null; + for (const b of m.blades) if (b.cut == null && (!pick || Math.abs(m.x + b.dx - mx) < Math.abs(m.x + pick.dx - mx))) pick = b; + if (pick) { pick.cut = t; m.left--; m.lastCut = t; } + o.bite = t + (o.kind === 'cow' ? 1.3 : 1) + Math.random() * 0.35; + if ((o.bites = (o.bites || 0) + 1) % 2 === 1) { + const words = o.kind === 'cow' ? ['crunch', 'munch', 'crunch'] : ['nom', 'nom nom', 'munch']; + this.doodle('munch', o.x + f * 23 * u * o.sc, o.y - 31 * u * o.sc, { text: words[((o.bites - 1) / 2 | 0) % 3], align: f > 0 ? 'left' : 'right', life: 1.15, calm: true }); + } + } + + /* ----- the sheep in a box ----- + A cardboard box with three air holes sits on the grass where it's seen, clear of the lighthouse, the cottage, + the little scenes and the animals. Now and then the sheep inside shifts and the box wobbles. When the light + finds it, two eyes look out through the holes (glinting at night); a click or a tap makes it hop and say baa, + louder each time, and the fifth in a row lets the sheep out: the flaps burst open, it jumps onto the grass and + joins the others, and the box is empty. Knocking on the empty box calls the sheep home: it trots back and hops + in. It isn't a surprise: clicking it drops nothing. Its label shows in the first screen while it's in view. */ + placeBox() { + const g = this.g, u = g.u, W = g.W, b = this.box, T = g.lh, ck = g.cot; + b.v = `${this.layoutV}|${this.fontsV}`; + b.size = Math.max(13, 17 * u); b.tw = textW(this, 'a sheep (in a box)', b.size); + // where its label mustn't go: the bar, the notes (a guess; the DOM has the last word), the key, the + // lighthouse and its label, the cottage + const noteX = W < 1024 ? W * 0.05 + Math.min(W * 0.58, 620) + 16 : g.note.x1 + 4; + b.hard = [ + [0, 0, W, g.top + 8], g.portrait ? [0, g.note.y0 - 10, W, g.H] : [0, 0, noteX, g.H], + [T.x - T.wB * 0.9, T.hatTop, T.x + T.wB * 0.9, T.base + 4 * u], [ck.x - ck.w * 0.7, ck.base - ck.h * 3, ck.x + ck.w * 0.7, ck.base + 4 * u], + ...lighthouseLabelRects(this, textW(this, 'a lighthouse (stable)', b.size), b.size), + ...(g.key ? [[g.key.x0, g.key.y0, g.key.x1, g.key.y1]] : []), + ]; + // a spot on the grass where it's seen, away from the animals, the tufts and the window's edges, with room + // for its label on the grass beside it + b.gx = b.gy = null; + const k = boxK(this); + let first = null, pick = null; + for (let i = 0; i < 14 && !pick; i++) { + const p = this.landSpot({ gap: 56 * u, spread: i < 7, check: 3, snug: true, clear: true }); + if (!p) break; + first ||= p; + if (!g.portrait && (p.x < noteX + 60 * u || p.x > W * 0.9)) continue; + if (this.boxSide({ x: p.x, y: p.y - BOX.hh * k, k })) pick = p; + } + if (pick ||= first) { b.gx = pick.x; b.gy = pick.y; } + b.seenAt = -9; b.nextWob = this.t + 4 + Math.random() * 6; + } + // the box right now: standing on the grass, its wobble, and (unless `still`, as for its label) the hop with its squash + boxAt(still) { + const b = this.box, k = boxK(this), rm = this.reduced, t = this.t; + let y = b.gy - BOX.hh * k, rot = 0, sq = 0; + const w = t - b.wob; + if (!rm && w >= 0 && w < 0.5) rot = Math.sin(w / 0.5 * Math.PI * 3) * 0.05 * (1 - w / 0.5); + const a = still ? -1 : t - b.hop; + const pa = t - b.burst; + if (!rm && b.out != null && pa >= 0 && pa < 0.25) sq += Math.sin(pa / 0.25 * Math.PI) * 0.12; + if (!rm && a >= 0 && a < 0.75) { + if (a < 0.08) sq = Math.sin(a / 0.08 * Math.PI) * 0.1; + else if (a < 0.52) { const f = (a - 0.08) / 0.44; y -= Math.sin(f * Math.PI) * 18 * k * b.hopK; rot -= Math.sin(f * Math.PI) * 0.14 * b.hopK; } + else sq = Math.sin((a - 0.52) / 0.23 * Math.PI) * 0.14; + } + return { x: b.gx, y, rot, sq, k }; + } + boxHit(x, y) { + if (this.box.gx == null) return false; + const p = this.boxAt(), k = p.k, pad = this.ptr.kind === 'touch' ? 14 : 7; + return x > p.x - BOX.hw * k - pad && x < p.x + (BOX.hw + BOX.dx) * k + pad && y > p.y + (BOX.dy - BOX.hh) * k - pad && y < p.y + BOX.hh * k + pad; + } + // the cottage's door, with a little room round it + doorHit(x, y) { + const d = this.g.cot.door, pad = this.ptr.kind === 'touch' ? 10 : 5; + return !!d && x > d.x - pad && x < d.x + d.w + pad && y > d.y - pad && y < d.y + d.h + pad; + } + // a knock: the door jumps and says so, a little more with each knock in a row; at the third the keeper comes to the + // door (see VISIT and drawVisit), unless he's out on the ridge with his tile (nobody comes) or already at the open + // door at night + knock() { + const k = this.g.cot, d = k.door, kn = this.knocks; + if (this.visit) return; + kn.n = this.t - kn.at < 2.5 ? kn.n + 1 : 1; + kn.at = this.t; + this.doodles = this.doodles.filter(o => o.kind !== 'knock'); + this.doodle('knock', k.x + k.w * 0.62, d.y + d.h * 0.3, { text: ['knock', 'knock knock', 'knock knock knock'][Math.min(kn.n, 3) - 1], align: 'left', life: 1.3 }); + if (kn.n < 3 || this.pres.install > 0.01) return; + kn.n = 0; + this.visit = { t0: this.t, away: this.pres.edit > 0.01, huh: false }; + } + // a click or a tap: it hops (not without motion) and says so, higher and louder with each knock in a row + baa() { + const b = this.box; + if (b.out != null) return this.callHome(); + b.knocks = this.t - b.knockAt < 2.5 ? b.knocks + 1 : 1; + b.knockAt = this.t; + if (b.knocks >= 5) return this.letOut(); + // it hops, unless it's still in the air from the knock before (a new hop there would drop it to the ground) + if (!this.reduced && !this.boxUp()) { b.hop = this.t; b.hopK = 1 + 0.2 * (b.knocks - 1); } + b.peek = this.t; + this.boxSays(['baa!', 'baa!', 'baa!!', 'BAA!!'][b.knocks - 1]); + } + // in the air from a hop: how long until it lands, or 0 + boxUp() { const a = this.t - this.box.hop; return a >= 0 && a < 0.55 ? 0.55 - a : 0; } + // beside it, toward the lighthouse, where the hop doesn't cover it; the newest word replaces the one before + boxSays(text) { + const p = this.boxAt(), k = p.k; + this.doodles = this.doodles.filter(d => d.kind !== 'baa'); + this.doodle('baa', p.x + (BOX.hw + BOX.dx + 6) * k, p.y + (BOX.dy - BOX.hh + 1) * k, { text, align: 'left', life: 1.4 }); + } + // the sheep is out of the box (or on its way out once the box has landed) + boxEmpty() { return this.box.out != null && this.t >= this.box.burst; } + // the fifth knock: once the box is on the ground, the flaps burst open and the sheep jumps out onto the grass a + // short hop away + letOut() { + const b = this.box, g = this.g, u = g.u, p = this.boxAt(true), k = p.k, wait = this.reduced ? 0 : this.boxUp(); + b.out = this.t; b.burst = this.t + wait; b.knocks = 0; b.peek = -9; b.wob = -9; + b.tw = textW(this, 'a box (empty)', b.size); + // it lands beside the box, in the same row, on whichever side has grass (the pasture's first) + const mid = p.x + BOX.dx * k * 0.5, reach = (BOX.hw + 34) * k; + let spot = null; + for (const dir of [1, -1]) for (const far of [1, 1.4, 0.8]) { + const x = mid + dir * reach * far; + if (!spot && x > 30 * u && x < g.W - 30 * u && this.ridgeY(x) != null && this.isLand(x, b.gy) && this.open(x, b.gy)) spot = { x, y: b.gy }; + } + spot ||= { x: clamp(mid + reach, 30 * u, g.W - 30 * u), y: b.gy }; + const o = this.spawn(spot.x, spot.y, 'sheep', true); + b.sheep = o; + if (!this.reduced) { + // from all the way down inside (the box is smaller than its sheep), up over the rim and down beside it + const x0 = p.x + BOX.dx * k * 0.5; + o.leap = { x0, y0: b.gy - 2 * BOX.hh * k + 30 * u * o.sc, x1: o.x, y1: o.ty, t0: b.burst, dur: 0.85, h: 58 * k }; + o.state = 'leap'; o.flip = o.x < x0; o.x = x0; o.y = o.leap.y0; + } + this.boxSays('baa!'); + this.wake(); + } + // the empty box knocked on: its sheep comes home (or, gone from the hill, is simply back) + callHome() { + const b = this.box, o = b.sheep, u = this.g.u; + if (!o || o.dead || o.dying != null || !this.objs.includes(o)) return this.closeBox(); + // without motion nobody walks: it's simply back in + if (this.reduced) { this.letGo(o); o.dead = true; this.objs = this.objs.filter(q => q !== o); return this.closeBox(); } + if (o.state === 'leap' || o.act === 'home') return; + this.letGo(o); + o.act = 'home'; o.until = this.t + 9; + this.doodle('baa', o.x + 14 * u * o.sc, o.y - 34 * u * o.sc, { text: 'baa?', align: 'left', life: 1.2 }); + this.wake(); + } + // where it goes to hop in: beside the box, on the side it comes from + homeSpot(o) { + const b = this.box, p = this.boxAt(true), k = p.k, mid = p.x + BOX.dx * k * 0.5; + const x = mid + (o.x < mid ? -1 : 1) * (BOX.hw + 22) * k, ry = this.ridgeY(x); + return ry == null || b.gx == null ? null : { x, d: b.gy - ry }; + } + hopIn(o) { + const b = this.box, p = this.boxAt(true), k = p.k; + this.letGo(o); + if (this.reduced || b.gx == null) { o.dead = true; this.closeBox(); return; } + const x1 = p.x + BOX.dx * k * 0.5; + o.leap = { x0: o.x, y0: o.y, x1, y1: b.gy - 2 * BOX.hh * k + 30 * this.g.u * o.sc, t0: this.t, dur: 0.7, h: 52 * k, in: true }; + o.state = 'leap'; o.act = 'idle'; o.flip = x1 < o.x; o.vx = o.vd = 0; + } + // the hop's end: on the grass, one of the flock now; or in the box, which closes over it + endLeap(o) { + const L = o.leap; + o.leap = null; + if (L.in) { o.dead = true; this.closeBox(); return; } + o.x = L.x1; o.y = o.ty; o.state = 'land'; o.land = this.t; o.act = null; + } + closeBox() { + const b = this.box; + b.out = null; b.sheep = null; b.peek = this.t; b.wob = this.t; + b.tw = textW(this, 'a sheep (in a box)', b.size); + this.wake(); + } + updateBox(dt) { + const b = this.box, rm = this.reduced, t = this.t; + // (the sheep's box is on the page's hill only) + if (this.held || this.cur !== this.w0) return; + if (b.v !== `${this.layoutV}|${this.fontsV}`) this.placeBox(); + if (b.gx == null) return; + const p = this.boxAt(), lit = this.inBeam(p.x + this.ox, p.y + this.oy); + b.lit = rm ? lit : lerp(b.lit, lit, Math.min(1, dt * 8)); + // the flaps: open while the sheep is out + const lid = this.boxEmpty() ? 1 : 0; + b.lid = rm ? lid : lerp(b.lid, lid, Math.min(1, dt * (lid ? 14 : 9))); + if (Math.abs(b.lid - lid) < 0.005) b.lid = lid; + // the eyes come to the holes while the light is on it (and for a moment after a baa), blinking now and then + const want = b.out == null && (b.lit > 0.4 || t - b.peek < 1.8); + if (want !== b.open) { b.open = want; b.openAt = t; b.nextBlink = t + 1.4 + Math.random() * 2; } + if (b.open && !rm && t > b.nextBlink) { b.blinkAt = t; b.nextBlink = t + 1.8 + Math.random() * 2.6; } + // now and then the sheep inside shifts + if (!rm && t > b.nextWob) { if (b.out == null) b.wob = t; b.nextWob = t + 7 + Math.random() * 9; } + // its label, in the first screen while it's in view: looked at a few times a second, faded in and out + if (t - b.seenAt > 0.3 || rm) { b.seenAt = t; b.side = this.pres.hero > 0.01 ? this.boxSide(this.boxAt(true)) : null; if (b.side) b.lastSide = b.side; } + b.la = rm ? (b.side ? 1 : 0) : lerp(b.la, b.side ? 1 : 0, Math.min(1, dt * 3)); + } + // the side of the box its label fits on right now (right, into the pasture, first; then left or below), or null: + // the box and the label in the window, on the grass, clear of what's in the way, and seen + boxSide(p) { + const g = this.g, u = g.u, W = g.W, k = p.k, hard = this.box.hard; + const r = [p.x - BOX.hw * k - 3, p.y + (BOX.dy - BOX.hh) * k - 3, p.x + (BOX.hw + BOX.dx) * k + 3, p.y + BOX.hh * k + 3]; + if (r[0] < 6 || r[2] > W - 6 || hard.some(q => overlaps(r, q)) || !this.seen(p.x, p.y)) return null; + for (const side of ['right', 'left', 'below']) { + const q = boxLabel(p.x, p.y, k, u, this.box.size, this.box.tw, side); + if (q.x0 < 6 || q.x1 > W - 6 || q.y1 > g.H - 6 || hard.some(h => overlaps([q.x0, q.y0, q.x1, q.y1], h))) continue; + if (!this.isLand(q.x0 + 4, (q.y0 + q.y1) / 2) || !this.isLand(q.x1 - 4, (q.y0 + q.y1) / 2)) continue; + if (this.seen((q.x0 + q.x1) / 2, (q.y0 + q.y1) / 2)) return side; + } + return null; + } + cloudXY(cl, raw) { + const g = this.g; + if (cl === g.sheepCloud) return { x: cl.x + (raw ? 0 : cl.ox), y: cl.y + (raw ? 0 : cl.oy) }; + const span = g.W + 360 * g.u; + let x = (cl.x0 + this.drift * cl.sp * g.u) % span; + if (x < 0) x += span; + return { x: x - 180 * g.u + (raw ? 0 : cl.ox), y: cl.y + (raw ? 0 : cl.oy) }; + } + sunPos() { const g = this.g, k = smooth(0, 0.62, this.d); return { x: g.sun.x, y: lerp(g.sun.y0, g.horizon + 30 * g.u, k), a: 1 - smooth(0.56, 0.66, this.d) }; } + moonPos() { const g = this.g, k = smooth(0.58, 1, this.d); return { x: g.moon.x, y: lerp(g.horizon + 24 * g.u, g.moon.y1, k), a: smooth(0.56, 0.7, this.d) }; } + waveY(x, row) { + const g = this.g, t = this.t, u = g.u, ph = this.reduced ? 0 : t; + let y = row.y + Math.sin(x * 0.011 / (0.35 + row.f) + ph * 1.15 + row.seed) * (0.5 + row.f * 2.4) * Math.max(u, 0.66) * (0.8 + 0.5 * Math.abs(this.windTotal)); + if (this.ptr.on && !this.reduced) { + const dx = x - this.ptr.x, dy = (row.y - this.ptr.y) * 2.2, d2 = dx * dx + dy * dy, r = 150 * u; + if (d2 < r * r) y += Math.sin(Math.sqrt(d2) * 0.085 / u - t * 6) * 5 * u * Math.exp(-d2 / (2 * 3600 * u * u)); + } + for (const r of this.ripples) { + const age = t - r.t0, rad = age * 150 * u, dx = x - r.x, dy = (row.y - r.y) * 2.4; + const dist = Math.sqrt(dx * dx + dy * dy), band = Math.exp(-((dist - rad) ** 2) / (2 * (16 * u) ** 2)); + if (band > 0.01) y += Math.sin(dist * 0.1 / u - age * 9) * 6 * u * r.a * band * (1 - age / 2.8); + } + return y; + } + squash(o) { if (!(o.land > 0)) return 0; const a = this.t - o.land; return a < 0.3 ? Math.sin(a / 0.3 * Math.PI) * 0.28 * (1 - a / 0.3) : 0; } + + /* ----- drawing ----- */ + render() { + const c = this.ctx, k = this.dpr, sw = this.swap; + this.pstep = Math.round(this.d * STEPS); + this.clock = this.t; + NAP = this.drowsy() > 0.9; + this.dress(this.shows); + c.setTransform(k, 0, 0, k, 0, 0); + c.globalAlpha = 1; c.globalCompositeOperation = 'source-over'; c.imageSmoothingQuality = 'high'; + this.page(() => this.style.before?.(this, c)); + DRAW.scene(this, c); + c.setTransform(k, 0, 0, k, 0, 0); + this.page(() => this.style.after?.(this, c)); + // a new style coming in: the old one where the light hasn't been yet (where it was to begin with, it has) + const hw = this.g.beamHalf; + if (sw && sw.a + hw < sw.a0 + TAU - hw) { + const { x, y } = sw.L; + c.save(); c.setTransform(1, 0, 0, 1, 0, 0); + c.beginPath(); c.moveTo(x * k, y * k); c.arc(x * k, y * k, this.diag * 2 * k, sw.a + hw, sw.a0 + TAU - hw); c.closePath(); c.clip(); + c.drawImage(this.was.canvas, 0, 0); + c.restore(); + } + this.t = this.clock; + } + // draws in the page's theme; in a wipe (see update), in the old one outside its circle and in the new one inside it + page(draw) { + const w = this.wipe, c = this.ctx, B = this.back; + if (!w) return draw(); + for (const out of [true, false]) { + c.save(); c.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + c.beginPath(); c.arc(B.W / 2, B.horizon, w.r, 0, TAU); + if (out) c.rect(0, 0, B.W, B.H); + c.clip('evenodd'); + if (!out) this.dress(w.to); + draw(); + c.restore(); + } + this.dress(this.shows); + } + /* Draws from here on in a theme's style (see STYLES): its levers in the pen's place, its palette at this hour, and + its time */ + dress(id) { + const st = this.style = STYLES[id], pw = this.g.pw; + this.t = st.fps ? Math.floor(this.clock * st.fps) / st.fps : this.clock; + this.boil = this.reduced || !st.boil ? 0 : Math.floor(this.t * st.boil) % 3; + TINT = st.tint; C = st.C; HAND = st.hand; WEIGHT = st.weight; CAPS = st.caps; ribbon = st.ribbon; print = st.print; DRAW = st.draw; + this.P = palAt(st.pal, this.pstep / STEPS); + BOIL = this.boil; PW = pw * st.pen; INK = rgba(this.P.ink); REGX = st.reg[0] * pw; REGY = st.reg[1] * pw; + } + /* A cached layer: one per boil state, redrawn when the hour, the theme or the layout changes, or when it's + wanted at another resolution (`res` times the screen's: islands far off are drawn small) */ + layer(c, name, rect, draw, res = 1) { + const key = `${this.layoutV}|${this.pstep}|${this.style.id}|${this.fontsV}|${res}`; + const L = (this.layers[name] ||= [])[this.boil] ||= { cv: document.createElement('canvas'), key: '' }; + const k = this.dpr * res, x0 = Math.floor(rect.x * k), y0 = Math.floor(rect.y * k), w = Math.ceil(rect.w * k), h = Math.ceil(rect.h * k); + if (L.key !== key) { + if (L.cv.width !== w || L.cv.height !== h) { L.cv.width = w; L.cv.height = h; } + const x = L.cv.getContext('2d'); + x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, w, h); + x.setTransform(k, 0, 0, k, -x0, -y0); + draw(x); + this.style.cached?.(this, x); + L.key = key; + this.rebuilds++; + } + const m = c.getTransform(); + c.save(); c.setTransform(m.a / k, 0, 0, m.d / k, m.e, m.f); c.drawImage(L.cv, x0, y0); c.restore(); + } + /* A small cached drawing (a cloud, the sun), drawn around its own origin */ + sprite(name, w, h, x, y, draw) { + const key = `${name}|${this.style.id}|${this.layoutV}|${this.boil}|${this.pstep}|${this.fontsV}`, k = this.dpr; + let s = this.sprites.get(key); + if (!s) { + const cv = document.createElement('canvas'); + cv.width = Math.ceil(w * k); cv.height = Math.ceil(h * k); + const c = cv.getContext('2d'); + c.setTransform(k, 0, 0, k, w * k / 2, h * k / 2); + draw(c); + this.style.cached?.(this, c); + s = { cv, w, h }; + this.sprites.set(key, s); + if (this.sprites.size > 60) this.sprites.delete(this.sprites.keys().next().value); + } + const c = this.ctx; + const m = c.getTransform(); + c.save(); c.setTransform(m.a / k, 0, 0, m.d / k, m.e, m.f); + c.drawImage(s.cv, Math.round((x - w / 2) * k), Math.round((y - h / 2) * k)); + c.restore(); + } +} + +/* ===================================================================== + THE DRAWING + Back to front: sky and sea, the cached band, stars, the scallops, the + beam, then the sun, moon, clouds and far island in front of it, the + cached hill with its cottage and tower, then everything that moves. + The far island is the first island of the dive, a whole one with its + own far island, and so on: each island draws in two passes, its light + behind the clouds and its land in front of them, the farthest first. + Diving, you go on over the sea toward the horizon, past the islands. + ===================================================================== */ +function drawScene(s, c) { + const g = s.g, { W, u } = g, shown = s.shown(); + c.lineCap = 'round'; c.lineJoin = 'round'; + // the page's sky and sea, the band cached in each theme it's in (see page) + s.page(() => { + DRAW.sky(s, c); + const bandTop = g.horizon - (g.horizon - g.top) * (g.portrait ? 0.42 : 0.36) - 20 * u; + s.layer(c, 'back' + s.style.id, { x: 0, y: bandTop, w: W, h: g.horizon - bandTop + 6 * u }, x => DRAW.band(s, x, bandTop)); + DRAW.stars(s, c); + DRAW.waves(s, c); + for (const w of shown) { s.enter(w); DRAW.ripples(s, c); } + c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + DRAW.sun(s, c); + DRAW.moon(s, c); + }); + // an island in a style, its theme's or the page's, and its ground in its theme's colours, as much darker than by day + // as that style's green is now; the page's hill in the visitor's. Then the style it was drawing in again + const wear = (w, id, draw) => { + const was = s.style.id; + s.dress(w.g.isle ? id : s.theme); + if (w.g.isle) { + const P = s.P, day = s.style.pal[0][1], [lit, shady] = w.g.ground; + P.hill = lit.map((v, i) => v * P.hill[i] / day.hill[i]); P.hill2 = shady.map((v, i) => v * P.hill2[i] / day.hill2[i]); + } + draw(); + s.dress(was); + }; + // an island in a style, laid over at `a`: when that's less than all of it, it's drawn whole on the side first + const lay = (w, id, a) => { + const x = a < 0.99 ? s.fade : c; + if (x !== c) { x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, x.canvas.width, x.canvas.height); x.lineCap = 'round'; x.lineJoin = 'round'; } + s.enter(w); view(s, x, w.view); + wear(w, id, () => DRAW.land(s, x, w)); + if (x !== c) { c.save(); c.setTransform(1, 0, 0, 1, 0, 0); c.globalAlpha = a; c.drawImage(x.canvas, 0, 0); c.restore(); } + }; + // an island comes out of the haze far off. Seen from the page it's in the page's style, and as the dive begins its + // own comes in over it (see OWN) + const land = w => { + const a = smooth(FAR, FAR * 1.6, w.view.s), k = w.g.isle ? smooth(0, OWN, s.dive) : 1; + if (k < 1) lay(w, s.theme, a); + if (k > 0) lay(w, w.g.theme, a * k); + }; + // the islands far off, the light over them, the clouds, then the nearest island + const near = shown.at(-1); + for (const w of shown) if (w !== near) land(w); + for (const w of shown) { + const a = lightK(w.view.s); + if (a > 0) { s.enter(w); view(s, c, w.view); wear(w, w.g.theme, () => { c.globalAlpha = a; DRAW.halo(s, c); c.globalAlpha = a; DRAW.beam(s, c); c.globalAlpha = 1; }); } + } + c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + s.page(() => { + for (let i = 0; i < g.clouds.length; i++) DRAW.cloud(s, c, g.clouds[i], 'cloud' + i); + DRAW.cloud(s, c, g.sheepCloud, 'sheepcloud', true); + }); + land(near); + s.enter(s.focus()); +} + +/* Only the island being looked at has its light on: it comes on as the island comes close, and goes out as the + island is passed, before it gets too much */ +const lightK = sc => smooth(0.45, 0.9, sc) * (1 - smooth(1, 2.2, sc)); + +/* an island's land and what's on it, back to front */ +function drawLand(s, c, w) { + DRAW.front(s, c, w.view.s); + c.globalAlpha = lightK(w.view.s); DRAW.hillLight(s, c); c.globalAlpha = 1; + DRAW.tufts(s, c); + DRAW.visit(s, c); + DRAW.smoke(s, c); + DRAW.eye(s, c); + DRAW.hat(s, c); + // animals and the grass on the hill, back to front (a tuft in front of the one eating it) + const items = [...s.objs.map(o => [o.leap ? s.box.gy + 0.25 : o.y, o]), ...s.meadow.map(m => [m.y + 0.5, m]), ...(s.box.gx != null ? [[s.box.gy, s.box]] : [])].sort((a, b) => a[0] - b[0]); + const wet = o => o.state === 'float' || o.state === 'swim' || (o.state === 'fall' && s.isSea(o.x, o.ty)); + for (const [, it] of items) { + if (wet(it)) { c.save(); if (afloat(s, c, it.state === 'fall' ? it.ty : it.y)) DRAW.obj(s, c, it); c.restore(); } + else it === s.box ? DRAW.box(s, c) : it.blades ? DRAW.meadow(s, c, it) : it.leap ? DRAW.leap(s, c, it) : DRAW.obj(s, c, it); + } + // the section's little scenes over them, on the page's hill: animals pass behind the desk, the huts and the keeper + if (w === s.w0) { + DRAW.states(s, c); + const lk = s.labelsK(); + if (s.pres.hero > 0.01 && lk > 0.01 && !s.dive) { c.globalAlpha = lk; DRAW.labels(s, c); c.globalAlpha = 1; } + } + DRAW.splashes(s, c); + DRAW.ball(s, c); + DRAW.doodles(s, c); +} + +/* the hill with its cottage and tower: cached at about the size it's drawn (an island's in each style it's drawn in), + or drawn as it is when larger than life */ +function drawFront(s, c, sc) { + const g = s.g, { W, H, u } = g, T = g.lh, x0 = g.shore ? g.ridge[0][0] - 8 : Math.max(0, g.ridge[0][0] - 10); + const x1 = Math.min(W, g.ridge.at(-1)[0] + 8), y0 = Math.min(T.top - T.gal * 1.2, g.cot.base - g.cot.h * 4.2) - 10 * u; + const y1 = g.shore ? Math.max(...g.shore.map(p => p[1])) + 26 * u : H; + const draw = x => { DRAW.hill(s, x); DRAW.cottage(s, x); DRAW.tower(s, x); }; + if (sc > 1) draw(c); + else s.layer(c, 'front' + (g.theme ? g.theme + s.style.id : ''), { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }, draw, 2 ** Math.ceil(Math.log2(sc))); +} + +/* draws from here on in an island's coordinates: screen = s * point + (x, y) */ +function view(s, c, v) { + const k = s.dpr; + c.setTransform(k * v.s, 0, 0, k * v.s, k * v.x, k * v.y); +} +/* Something on the sea at y by the island being drawn isn't as far off as the island: it's as deep into the sea as on + the page (see waveScallops), and as much further as the island is, so it comes on faster. Draws from here on where + it is, unless it has gone past. */ +function afloat(s, c, y) { + const g = s.g, at = (g.H - g.horizon) / (y - g.horizon), d = at + (1 / s.cur.view.s - 1) / SHORE; + if (d < 0.2) return false; + const m = at / d; + view(s, c, { s: m, x: g.W / 2 * (1 - m), y: g.horizon * (1 - m) }); + return true; +} + +/* the sky and the sea, flat */ +function drawSky(s, c) { + const g = s.g, P = s.P; + c.fillStyle = rgba(P.sky); c.fillRect(0, 0, g.W, g.horizon + 2); + c.fillStyle = rgba(P.sea); c.fillRect(0, g.horizon, g.W, g.H - g.horizon); +} + +/* the lower sky, a lighter band with a soft wave at its top, and the horizon */ +function drawBand(s, c, bandTop) { + const g = s.g, { W, u } = g, P = s.P; + const by = bandTop + 20 * u, band = []; + for (let x = -30; x <= W + 30; x += 16 * u) band.push([x, by + Math.sin(x * 0.006 + 1) * 11 * u + Math.sin(x * 0.019 + 2) * 3 * u]); + band.push([W + 30, g.horizon + 4], [-30, g.horizon + 4]); + poly(c, band); c.fillStyle = rgba(P.sky2); c.fill(); + c.beginPath(); ribbon(c, dens([[-10, g.horizon], [W + 10, g.horizon]], 8), DET, 2, { amp: 0.6, tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); +} + +function drawStars(s, c) { + const g = s.g, u = g.u, st = smooth(0.55, 0.9, s.d); + if (st <= 0) return; + c.beginPath(); + for (const p of g.stars) { + if (p.s < 0.75) continue; + const r = p.s * 2.7 * u * (s.reduced ? 1 : 0.78 + 0.22 * Math.sin(s.t * 2 + p.p)); + for (let k = 0; k < 3; k++) { + const a = k / 3 * Math.PI + p.p + BOIL * 0.07; + ribbon(c, [[p.x - Math.cos(a) * r, p.y - Math.sin(a) * r], [p.x + Math.cos(a) * r, p.y + Math.sin(a) * r]], 1.7, k, { amp: 0.15, tin: 1, tout: 1, w0: 0.7, w1: 0.6 }); + } + } + c.fillStyle = rgba(s.P.beam, st); c.fill(); + // and a few small dots between them + c.fillStyle = rgba(s.P.beam, st * 0.8); + for (const p of g.stars) if (p.s < 0.75) { c.beginPath(); c.arc(p.x, p.y, 0.9 * u + 0.4, 0, TAU); c.fill(); } +} + +/* a fried-egg sun that sets into the sea, behind the far island */ +function drawSun(s, c) { + const g = s.g, u = g.u, sun = s.sunPos(); + if (sun.a <= 0) return; + const r = 38 * u, sz = r * 3; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); + c.globalAlpha = sun.a; + s.sprite('sun', sz, sz, sun.x, sun.y, x => { + const white = []; + for (let i = 0; i < 48; i++) { const a = i / 48 * TAU; white.push([Math.cos(a) * r * (1 + 0.12 * Math.sin(a * 3 + 1) + 0.06 * Math.sin(a * 5 + 2)), Math.sin(a) * r * 0.86 * (1 + 0.09 * Math.sin(a * 4))]); } + shape(x, dens(white, 4, true), mul(hex('#FFFFFF'), s.P.paper), DET, 5); + const yolk = ellipse(3 * u, -3 * u, r * 0.42, r * 0.4, 30); + shape(x, yolk, C.yolk, DET * 0.9, 7); + // the shine on the yolk + x.beginPath(); ribbon(x, ellipse(3 * u, -3 * u, r * 0.27, r * 0.25, 10, Math.PI * 1.1, Math.PI * 1.55), TEX * 1.3, 3, { tin: 2, tout: 2 }); x.fillStyle = '#FFFFFF'; x.fill(); + dot(x, 3 * u - r * 0.12, -3 * u - r * 0.27, 1.3 * u + 0.4, '#FFFFFF'); + }); + c.restore(); +} + +/* a crescent moon, asleep, with a z */ +function drawMoon(s, c) { + const g = s.g, u = g.u, m = s.moonPos(); + if (m.a <= 0) return; + const r = 25 * u, sz = r * 3.4; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); + c.globalAlpha = m.a; + s.sprite('moon', sz, sz, m.x, m.y, x => { + const cres = []; + for (let i = 0; i <= 26; i++) { const a = -Math.PI / 2 + i / 26 * Math.PI; cres.push([Math.cos(a) * r, Math.sin(a) * r]); } + for (let i = 25; i >= 1; i--) { const a = -Math.PI / 2 + i / 26 * Math.PI; cres.push([Math.cos(a) * r * 0.38 + r * 0.02, Math.sin(a) * r * 0.97]); } + shape(x, dens(cres, 3, true), C.moon, DET, 11); + // a closed eye with lashes, and a small smile + x.beginPath(); + ribbon(x, ellipse(r * 0.62, -r * 0.2, r * 0.17, r * 0.11, 9, 0.15 * Math.PI, 0.85 * Math.PI), TEX * 1.2, 2, { tin: 2, tout: 2 }); + for (const k of [0.3, 0.5, 0.7]) { const a = k * Math.PI, ex = r * 0.62 + Math.cos(a) * r * 0.17, ey = -r * 0.2 + Math.sin(a) * r * 0.11; ribbon(x, [[ex, ey], [ex + Math.cos(a) * 3 * u, ey + Math.sin(a) * 3 * u]], TEX, k * 9, { tin: 1, tout: 1 }); } + ribbon(x, ellipse(r * 0.52, r * 0.3, r * 0.15, r * 0.1, 9, 0.2 * Math.PI, 0.8 * Math.PI), TEX * 1.1, 4, { tin: 2, tout: 2 }); + x.fillStyle = INK; x.fill(); + }); + c.restore(); + { + const zz = s.reduced ? 0.4 : (s.t * 0.4) % 1, col = rgba(mix(s.P.paper, s.P.beam, 0.3), m.a * (1 - zz) * 0.95); + label(c, 'z', m.x + r * 1.1 + zz * 10 * u, m.y - r * 0.7 - zz * 16 * u, (11 + zz * 5) * u + 3, col, 'left', -0.2); + } +} + +function drawCloud(s, c, cl, name, legs) { + const g = s.g, u = g.u, P = s.P, p = s.cloudXY(cl), w = cl.w; + if (p.x < -w || p.x > g.W + w) return; + if (legs) { + // four legs, walking when it walks + const walk = s.reduced ? 0 : s.t * 5; + c.beginPath(); + const feet = []; + [-0.24, -0.11, 0.1, 0.23].forEach((lx, i) => { + const x = p.x + lx * w * (cl.dir < 0 ? -1 : 1), sw = Math.sin(walk + i * 1.7 + (i % 2) * Math.PI) * 2.6 * u; + ribbon(c, [[x, p.y - 4 * u], [x + sw * 0.5, p.y + 5 * u], [x + sw, p.y + 12 * u]], DET * 1.25, i * 3, { tin: 2, tout: 2, w0: 0.9, w1: 0.9 }); + feet.push([x + sw, p.y + 13 * u]); + }); + c.fillStyle = INK; c.fill(); + for (const [fx, fy] of feet) { c.beginPath(); c.ellipse(fx + 0.6 * u, fy, 2.6 * u, 1.9 * u, 0, 0, TAU); c.fill(); } + } + const sz = w * 1.3; + s.sprite(name + (legs && cl.dir < 0 ? 'L' : ''), sz, w * 0.75, p.x, p.y - w * 0.12, x => { + const top = cloudPts(0, w * 0.12, w, cl.seed); + const pts = dens(top, 5, true); + print(x, pts, P.cloud); + x.beginPath(); + loop(x, pts, DET, cl.seed % 13); + // a curl or two of puff inside, drawn lightly + const R = rng(cl.seed + 5); + for (let i = 0; i < 2; i++) { + const cx = (R() - 0.5) * w * 0.5, cy = w * 0.02 - R() * w * 0.06, rr = w * (0.07 + R() * 0.05); + ribbon(x, ellipse(cx, cy, rr, rr * 0.8, 10, Math.PI * 1.05, Math.PI * 1.75), TEX, i + 3, { tin: 2, tout: 3 }); + } + x.fillStyle = INK; x.fill(); + if (legs) { + // a tail, and the hint of an ear: it might be a sheep + const f = cl.dir < 0 ? -1 : 1; + x.beginPath(); + ribbon(x, [[-f * w * 0.5, w * 0.02], [-f * w * 0.57, -w * 0.03], [-f * w * 0.6, w * 0.03]], TEX, 4, { tin: 2, tout: 2 }); + x.fillStyle = INK; x.fill(); + } + }); +} + +/* the sheep's box: kraft cardboard, a strip of tape over the flaps, three air holes; while the light is on + it, eyes in two of them that look at the lamp, blink, and glint at night */ +function drawBox(s, c) { + const b = s.box; + if (b.gx == null) return; + const g = s.g, P = s.P, p = s.boxAt(), k = p.k, t = s.t, rm = s.reduced, { hw, hh, dx, dy } = BOX; + const night = smooth(0.5, 0.9, s.d), card = col => mix(mul(col, P.paper), P.sky, 0.2 * night); + c.save(); c.translate(p.x, p.y + hh * k); c.rotate(p.rot); c.scale(1 + p.sq * 0.6, 1 - p.sq); c.translate(0, -hh * k); + const K = q => q.map(([x, y]) => [x * k, y * k]); + const front = K([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]); + const top = K([[-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw, -hh]]); + const side = K([[hw, -hh], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]); + // with the sheep out the flaps stand open, up from the sides, over the dark inside + const lid = b.lid, th = lid * 1.95, flaps = []; + print(c, side, card(C.kraftDark)); print(c, top, lid > 0.01 ? mix(card(C.kraftDark), P.ink, 0.4) : card(C.kraftLit)); print(c, front, card(C.kraft)); + if (lid > 0.01) for (const sd of [-1, 1]) { + const hx = sd * hw, wx = -sd * hw * Math.cos(th), wy = -hw * Math.sin(th); + const flap = K([[hx, -hh], [hx + dx, -hh + dy], [hx + dx + wx, -hh + dy + wy], [hx + wx, -hh + wy]]); + print(c, flap, card(sd < 0 ? C.kraftLit : C.kraft)); + flaps.push(flap); + } else { + // tape over the seam between the flaps, front to back, folded a little way down the front + c.globalAlpha = 0.8; + print(c, K([[-3.4, -hh], [-3.4 + dx, -hh + dy], [3.4 + dx, -hh + dy], [3.4, -hh], [3.6, -hh + 6.5], [0.4, -hh + 5.8], [-3.6, -hh + 6.8]]), card(C.tape)); + c.globalAlpha = 1; + } + // the holes, dark; eyes in two of them while the light is on the box (or just after a baa) + let open = b.open ? (rm ? 1 : smooth(0, 0.2, t - b.openAt)) : (rm ? 0 : 1 - smooth(0, 0.14, t - b.openAt)); + if (b.open && !rm && t - b.blinkAt < 0.16) open *= Math.abs(Math.cos((t - b.blinkAt) / 0.16 * Math.PI)); + const L = s.g.lamp, look = Math.atan2(L.y - p.y, L.x - p.x), r = 3.4 * k; + BOX.holes.forEach(([hx, hy], i) => { + const x = hx * k, y = hy * k; + dot(c, x, y, r, mix(P.ink, C.kraftDark, 0.18)); + if (i > 1 || open < 0.04) return; + const ew = r * 0.8, eh = r * 0.8 * open; + c.beginPath(); c.ellipse(x, y + (1 - open) * r * 0.3, ew, Math.max(0.3, eh), 0, 0, TAU); c.fillStyle = rgba(mix(hex('#FFFFFF'), P.beam, 0.4 * night)); c.fill(); + c.save(); c.clip(); + const px = x + Math.cos(look) * r * 0.32, py = y + Math.sin(look) * r * 0.28; + dot(c, px, py, r * 0.44, INK); + dot(c, px - r * 0.16, py - r * 0.2, r * 0.15 + 0.25, '#FFFFFF'); + c.restore(); + }); + // the pen: the outline in one weight, the edges inside lighter, a ring round each hole, and this side up + c.beginPath(); + edges(c, K([[-hw, hh], [-hw, -hh], [-hw + dx, -hh + dy], [hw + dx, -hh + dy], [hw + dx, hh + dy], [hw, hh]]), DET * 1.05, 3, true, { ov: 1.5 }); + edges(c, K([[-hw, -hh], [hw, -hh], [hw, hh]]), TEX * 1.25, 9, false, { ov: 0.8 }); + edges(c, K([[hw, -hh], [hw + dx, -hh + dy]]), TEX * 1.25, 12, false, { ov: 0.6 }); + flaps.forEach((f, i) => edges(c, f, TEX * 1.25, 40 + i, true, { ov: 0.8 })); + BOX.holes.forEach(([hx, hy], i) => ribbon(c, ellipse(hx * k, hy * k, r, r, 12, i), TEX, 30 + i, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.2, outline: true })); + for (const ax of [hw + dx * 0.32, hw + dx * 0.7]) { + const x = ax * k, y0 = (dy * (ax - hw) / dx + 6) * k, y1 = y0 - 7 * k; + ribbon(c, [[x, y0], [x, y1]], TEX * 0.9, ax, { tin: 1, tout: 1, w0: 0.8, w1: 0.8, amp: 0.1 }); + ribbon(c, [[x - 1.7 * k, y1 + 2.4 * k], [x, y1], [x + 1.7 * k, y1 + 2.4 * k]], TEX * 0.9, ax + 1, { tin: 1, tout: 1, w0: 0.8, w1: 0.8, amp: 0.1 }); + } + c.fillStyle = INK; c.fill(); + // and at night the eyes glint, a little star at each + if (open > 0.3 && night > 0.05) { + c.beginPath(); + for (let i = 0; i < 2; i++) { + const [hx, hy] = BOX.holes[i], x = hx * k + r * 0.95, y = hy * k - r * 1.05, l = (1.4 + 1.2 * night) * k * open; + for (const an of [0.2, 0.2 + Math.PI / 2]) ribbon(c, [[x - Math.cos(an) * l, y - Math.sin(an) * l], [x + Math.cos(an) * l, y + Math.sin(an) * l]], TEX * 0.9, i * 2 + an, { tin: 1, tout: 1, w0: 0.5, w1: 0.5, amp: 0.1 }); + } + c.fillStyle = rgba(P.beam, clamp(night * open * 1.3)); c.fill(); + } + c.restore(); +} + +/* a sheep hopping out of its box or back in: in front of the box, and hidden by its front while inside it */ +function drawLeap(s, c, o) { + if (s.t < o.leap.t0) return; + const L = o.leap, f = clamp((s.t - L.t0) / L.dur), p = s.boxAt(), k = p.k; + const inside = Math.abs(o.x - (p.x + BOX.dx * k * 0.5)) < BOX.hw * k && (L.in ? f > 0.5 : f < 0.5); + if (!inside) { DRAW.obj(s, c, o); return; } + c.save(); c.beginPath(); c.rect(-1e4, -1e4, 2e4, 1e4 + p.y - BOX.hh * k + 1); c.clip(); + DRAW.obj(s, c, o); + c.restore(); +} + +/* the sea: scallops on the water as it goes off to the horizon, in rows, closer together and sparser the farther off; + ripples where things land. Diving, you go over it toward the horizon, as fast as past the islands (an island's shore + is about a quarter of the way down the sea), and rows and scallops come in between the others as they come close and spread. */ +const rank = n => n ? 31 - Math.clz32(n & -n) : 32; +function waveScallops(s, c, lit) { + const g = s.g, { W, H, u } = g, hz = g.horizon, sea = H - hz, mid = W / 2; + // depths in rows ROW apart from where you are, 1 at the bottom of the screen; across, scallops ACROSS apart there + const ROW = 0.02, ACROSS = 4, go = s.dive * DIVE_GAP / SHORE, far = lit ? 0.004 : 0.0175, alpha = c.globalAlpha; + for (let k = Math.ceil((1 + go) / ROW); ;) { + const rel = 1 / (k * ROW - go); + if (rel < far) break; + // the gap between rows here on the page, and the rows that keep it: the ones `step` apart, those of the finest + // of them fading in + const f = rel ** (1 / 1.6), gap = 3.2 * sea * rel ** 0.375 / g.rows, dy = sea * ROW * rel * rel; + const step = 2 ** Math.max(0, Math.ceil(Math.log2(gap / dy))); + if (k % step) { k += step - k % step; continue; } + const a = rank(k) > Math.log2(step) ? 1 : clamp(dy * step / gap - 1); + k += step; + if (a <= 0) continue; + const row = { y: hz + sea * rel, f, seed: (k - step) * 97 + s.o.seed }, q0 = k - step; + const size = (16 + 34 * f) * Math.max(u, 0.66), across = ACROSS * rel, room = size * 1.7; + const qs = 2 ** Math.max(0, Math.ceil(Math.log2(room / across))), qa = clamp(across * qs / room - 1); + const off = hash(q0 * 0.13) * 64 * across; + c.lineWidth = (0.75 + 1.3 * f) * g.pw * (lit ? 1.15 : 1); + for (const fading of [false, true]) { + c.beginPath(); + for (let q = Math.floor((-8 - mid - off) / across / qs) * qs; mid + off + q * across < W + 8 + size; q += qs) { + if ((rank(Math.abs(q)) === Math.log2(qs)) !== fading || hash(q * 0.37 + q0 * 3.1) < 0.3 - f * 0.12) continue; + const x = mid + off + q * across - size / 2, y = s.waveY(x, row), y2 = s.waveY(x + size, row); + c.moveTo(x, y); c.quadraticCurveTo(x + size / 2, (y + y2) / 2 - size * 0.34, x + size, y2); + } + c.globalAlpha = alpha * a * (fading ? qa : 1); c.stroke(); + } + } + c.globalAlpha = alpha; +} +function drawWaves(s, c) { + c.strokeStyle = rgba(s.P.sea2); waveScallops(s, c, false); +} +function drawRipples(s, c) { + const u = s.g.u, P = s.P; + for (const r of s.ripples) { + if (!afloat(s, c, r.y)) continue; + const age = s.t - r.t0, rx = 8 * u + age * 70 * u; + c.beginPath(); ribbon(c, ellipse(r.x, r.y, rx, rx * 0.3, 40), DET * 0.8, 4, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = rgba(P.foam, clamp(1 - age / 2.2) * r.a); c.fill(); + } +} + +/* the light: a flat yellow fan with felt-pen edges, brighter at night (fainter, pen and all, while it's weak) */ +function beamPoly(s) { + const L = s.g.lamp, B = s.beam, len = B.len * s.diag * 1.05, hw = s.g.beamHalf; + return { L, len, a0: B.a - hw, a1: B.a + hw }; +} +function drawBeam(s, c) { + const g = s.g, u = g.u, P = s.P, I = s.beamI(), pw = s.lampPower(), { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || pw < 0.02) return; + const night = smooth(0.3, 0.8, s.d); + const e0 = [L.x + Math.cos(a0) * len, L.y + Math.sin(a0) * len], e1 = [L.x + Math.cos(a1) * len, L.y + Math.sin(a1) * len]; + const fan = [[L.x, L.y], e0]; + for (let i = 1; i < 12; i++) { const a = lerp(a0, a1, i / 12); fan.push([L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]); } + fan.push(e1); + const col = mix(P.beam, hex('#FFF6C2'), night * 0.45); + poly(c, fan); c.fillStyle = rgba(col, (0.84 + 0.12 * night * I) * pw); c.fill(); + // the scallops it crosses light up (the sea's, on the screen) + c.save(); poly(c, fan); c.clip(); c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + c.strokeStyle = rgba(mix(col, hex('#FFFFFF'), 0.5), 0.95 * pw); waveScallops(s, c, true); + c.restore(); + // the pen goes along both edges, from just outside the eye + const r0 = g.lh.er * 2.3; + c.beginPath(); + for (const [a, sd] of [[a0, 13], [a1, 17]]) ribbon(c, dens([[L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0], [L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]], 9), DET * 0.95, sd, { tin: 20, tout: 1, w0: 0.2, w1: 1 }); + if (s.beam.len < 0.85) ribbon(c, dens(fan.slice(1), 6), DET * 0.9, 19, { tin: 3, tout: 3 }); + c.globalAlpha = 0.3 + 0.7 * pw; c.fillStyle = INK; c.fill(); c.globalAlpha = 1; +} + +/* where the light lands on the hill: a flat, lighter print on the grass */ +function drawHillLight(s, c) { + const { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || Math.sin(s.beam.a) < 0.08) return; + const night = smooth(0.3, 0.8, s.d); + c.save(); poly(c, s.g.hillClip); c.clip(); + c.beginPath(); c.moveTo(L.x, L.y); + for (let i = 0; i <= 12; i++) { const a = lerp(a0, a1, i / 12); c.lineTo(L.x + Math.cos(a) * len, L.y + Math.sin(a) * len); } + c.closePath(); c.fillStyle = rgba(mix(s.P.hill, s.P.beam, (0.5 + 0.2 * night) * s.lampPower())); c.fill(); + c.restore(); +} + +/* the lamp's halo at night: hand-drawn rings and short rays, no blur */ +function drawHalo(s, c) { + const g = s.g, u = g.u, P = s.P, L = g.lamp, er = g.lh.er; + const night = smooth(0.35, 0.85, s.d), f = s.beam.flash, k = Math.max(night * s.beamI(), f * 0.9) * s.lampOn() * s.lampPower(); + if (k < 0.02) return; + const r1 = er * (2.1 + f * 0.6), r2 = er * (3 + f * 1.4); + // the lit lantern: a flat disc of light just around the eye + c.globalAlpha = clamp(k * 1.4); + print(c, ellipse(L.x, L.y, er * 1.75, er * 1.6, 30), mix(P.beam, hex('#FFFFFF'), 0.25)); + c.globalAlpha = 1; + c.beginPath(); + ribbon(c, ellipse(L.x, L.y, r1, r1, 30, 0.3, TAU - 0.2), TEX, 7, { tin: 4, tout: 6 }); + ribbon(c, ellipse(L.x, L.y, r2, r2, 40, 1.4, TAU + 0.6), TEX, 9, { tin: 4, tout: 8 }); + const spin = s.reduced ? 0 : s.t * 0.25; + for (let i = 0; i < 14; i++) { + const a = i / 14 * TAU + spin + (i % 2) * 0.12, a1 = r2 * (1.15 + (i % 3) * 0.06), a2 = r2 * (1.42 + (i % 2) * 0.22 + f * 0.5); + ribbon(c, [[L.x + Math.cos(a) * a1, L.y + Math.sin(a) * a1], [L.x + Math.cos(a) * a2, L.y + Math.sin(a) * a2]], TEX, i, { tin: 1, tout: 2, w0: 0.7 }); + } + c.fillStyle = rgba(P.beam, clamp(k * 1.1)); c.fill(); +} + +/* the hill: printed green, a shaded flank, the ridge in the heaviest line, a few hatches and flowers */ +function drawHill(s, c) { + const g = s.g, { W, H, u } = g, P = s.P, T = g.lh, rd = g.ridge; + // an island sits in the water: under its shore, the hill's darker reflection, a few strokes of water across it + if (g.isle) { + const sl = g.shore, x0 = sl[0][0], x1 = sl.at(-1)[0], deep = x => Math.sin(Math.PI * (x - x0) / (x1 - x0)) * 18 * u, R = rng(3); + print(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x, y + deep(x)])], mix(P.sea, P.hill2, 0.45)); + c.beginPath(); + for (let j = 0; j < 4; j++) for (let x = x0 + R() * 30 * u; x < x1 - 20 * u; x += (24 + R() * 30) * u) { + const y = s.shoreY(x) + deep(x) * (0.25 + j * 0.27) + 2 * u; + c.moveTo(x, y); c.lineTo(x + (6 + R() * 12) * u * (1 - j * 0.15), y); + } + c.strokeStyle = rgba(P.sea2); c.lineWidth = 1.3 * g.pw; c.stroke(); + } + print(c, g.hillPoly, P.hill); + // the shady flank, a second flat green + const sh = []; + for (const [x, y] of rd) if (x < T.x - T.wB * 2) sh.push([x, y]); + if (g.shore) { + // under the headland, a darker band of cliff above the shore + const sl = g.shore, x0 = sl[0][0]; + print(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x + 4 * u, y - (8 + (x - x0) / W * 26) * u])], P.hill2); + } else { + // it narrows to nothing near the top, so it reads as the hill turning away from the light + const n = sh.length, inner = sh.map(([x, y], i) => { const f = smooth(0, 0.35, (n - 1 - i) / n); return [x + (34 + (H - y) * 0.12) * u * f, y + (16 + (H - y) * 0.05) * u * f]; }).reverse(); + print(c, [...sh, ...inner], P.hill2); + } + // hatching on the shady side: a few short groups, lightest line + c.beginPath(); + const R = rng(5); + const groups = g.portrait ? 7 : 11; + for (let i = 0; i < groups; i++) { + const fx = R(), x = g.portrait ? lerp(rd[0][0] + 20 * u, T.x - T.wB * 3, fx) : lerp(rd[0][0] + 30 * u, T.x - T.wB * 2.2, fx); + const ry = s.ridgeY(x); + if (ry == null) continue; + const y = ry + (14 + R() * (g.portrait ? 26 : 70)) * u; + if (g.shore && y > s.shoreY(x) - 12 * u) continue; + const n = 3 + ((R() * 3) | 0), l = (13 + R() * 10) * u; + for (let k = 0; k < n; k++) { + const hx = x + k * 5 * u, hy = y + k * 1.6 * u, lk = l * (1 - Math.abs(k - (n - 1) / 2) * 0.16); + ribbon(c, [[hx, hy], [hx + lk * 0.42, hy + lk]], TEX, i * 5 + k, { tin: 3, tout: 5, w0: 0.45, w1: 0.2, amp: 0.4, hatch: true }); + } + } + c.fillStyle = INK; c.fill(); + // foam where the hill meets the sea + c.beginPath(); + const foam = g.shore || rd.filter(p => p[1] > g.horizon + 3 && p[0] < T.x); + for (let i = 2; i < foam.length - 2; i += 3) { + const [x, y] = foam[i], [x2, y2] = foam[i + 2], nx = g.shore ? 0 : -1, ny = g.shore ? 1 : 0; + const ox = nx * 5 * u, oy = ny * 5 * u, mx = (x + x2) / 2 + ox, my = (y + y2) / 2 + oy; + if (mx < -10 || mx > W + 10 || my > H + 10 || hash(i * 0.7) < 0.3) continue; + ribbon(c, [[x + ox, y + oy], [mx + nx * 2 * u - (g.shore ? 0 : 1) * u, my + (g.shore ? 3 : -2) * u], [x2 + ox, y2 + oy]], TEX * 1.1, i, { tin: 1, tout: 1, amp: 0.3 }); + } + c.fillStyle = rgba(P.foam); c.fill(); + // the ridge, and the shore under it if it has one + c.beginPath(); + ribbon(c, rd.filter(p => p[0] > -20 && p[0] < W + 20), SIL, 21, { tin: 4, tout: 4, w0: 0.8, w1: 0.8, outline: true }); + if (g.shore) ribbon(c, g.shore.filter(p => p[0] < W + 20), SIL * 0.9, 23, { tin: 30, tout: 4, w0: 0.3, w1: 0.8, outline: true }); + c.fillStyle = INK; c.fill(); + // daisies near the cottage and the tower, where the eye goes + const D = rng(9); + for (let i = 0; i < (g.portrait ? 6 : 11); i++) { + const x = lerp(T.x - T.h * 0.42, Math.min(W - 10, g.cot.x + g.cot.w * 1.6), D()), ry = s.ridgeY(x); + if (ry == null || Math.abs(x - T.x) < T.wB * 0.8 || Math.abs(x - g.cot.x) < g.cot.w * 0.6) { D(); continue; } + const y = ry + (8 + D() * (g.portrait ? 20 : 46)) * u, r = 2.2 * u; + for (let k = 0; k < 5; k++) { const a = k / 5 * TAU; dot(c, x + Math.cos(a) * r + REGX * 0.5, y + Math.sin(a) * r + REGY * 0.5, 1.5 * u, mul(hex('#FFFFFF'), P.paper)); } + dot(c, x + REGX * 0.5, y + REGY * 0.5, 1.4 * u, C.yolk); + } +} + +function drawCottage(s, c) { + const g = s.g, u = g.u, P = s.P, k = g.cot, w = k.w, h = k.h, x0 = k.x - w / 2, y0 = k.base - h; + const paper = mul(hex('#FFFFFF'), P.paper), night = smooth(0.45, 0.75, s.d); + // the chimney, a little crooked + const chx = x0 + w * 0.68, chw = w * 0.15, cht = y0 - h * 1.45; + const chim = [[chx, y0 - h * 0.2], [chx + 1.2 * u, cht], [chx + chw + 1.6 * u, cht], [chx + chw, y0 - h * 0.25]]; + print(c, chim, mul(C.chimney, P.paper)); + c.beginPath(); edges(c, chim, DET, 3, false); + ribbon(c, dens([[chx - 1.5 * u, cht + 3 * u], [chx + chw + 3 * u, cht + 2.5 * u]], 4), DET * 1.15, 4, { tin: 2, tout: 2 }); + c.fillStyle = INK; c.fill(); + // walls + const wall = [[x0, k.base], [x0, y0], [x0 + w, y0], [x0 + w, k.base]]; + print(c, wall, paper); + // the roof, inked solid as felt-pen fills are + const roof = [[x0 - w * 0.12, y0 + 1 * u], [x0 + w * 0.5, y0 - h * 0.78], [x0 + w * 1.12, y0 + 1 * u]]; + poly(c, roof); c.fillStyle = rgba(C.roof); c.fill(); + c.beginPath(); edges(c, roof, DET * 1.1, 6, true); edges(c, wall, SIL * 0.85, 8, false); c.fillStyle = INK; c.fill(); + // the window: four panes, lit at night + const wx = x0 + w * 0.14, wy = y0 + h * 0.24, ww = w * 0.27, wh = h * 0.36; + print(c, [[wx, wy], [wx + ww, wy], [wx + ww, wy + wh], [wx, wy + wh]], night > 0.5 ? P.beam : P.glass); + c.beginPath(); edges(c, [[wx, wy], [wx + ww, wy], [wx + ww, wy + wh], [wx, wy + wh]], DET * 0.85, 11, true, { ov: 1.2 }); + ribbon(c, [[wx + ww / 2, wy + 1], [wx + ww / 2, wy + wh - 1]], TEX, 12, { tin: 1, tout: 1, w0: 0.8, w1: 0.8 }); + ribbon(c, [[wx + 1, wy + wh / 2], [wx + ww - 1, wy + wh / 2]], TEX, 13, { tin: 1, tout: 1, w0: 0.8, w1: 0.8 }); + c.fillStyle = INK; c.fill(); + // the door (it opens for the keeper: see the states and drawVisit) + const dx = x0 + w * 0.56, dw = w * 0.24, dh = h * 0.66; + k.door = { x: dx, y: k.base - dh, w: dw, h: dh }; + drawDoor(s, c); +} + +/* the cottage's door, hinged on its right: shut, or swung out (open 1: against the wall), and moved by a knock (jig). + Shut and still it's the one drawn with the cottage, so the knocks and the keeper's visit draw it the same way. Its + handle is on the outside: it goes with the door as it opens, out of sight once the door is past square */ +function drawDoor(s, c, open = 0, jig = 0) { + const u = s.g.u, k = s.g.cot, d = k.door, hx = d.x + d.w + jig, fx = lerp(d.x, d.x + d.w * 1.55, open) + jig; + const door = [[fx, k.base - 1 * u * open], [fx, d.y + 2 * u * open], [hx, d.y], [hx, k.base]]; + print(c, door, mul(C.door, s.P.paper)); + c.beginPath(); edges(c, door, DET * 0.85, 14, false, { ov: 1 }); c.fillStyle = INK; c.fill(); + if (hx - fx > 3 * u) dot(c, lerp(fx, hx, 0.78), k.base - d.h * 0.48, 1.3 * u + 0.4, INK); +} + +/* the tower: white with curved stripes in the theme's colours, a door, two windows and a gallery */ +function drawTower(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, th = THEMES[g.theme || s.theme]; + const paper = mul(hex('#FFFFFF'), P.paper); + const half = k => lerp(T.wB, T.wT, k) / 2, yAt = k => T.base - T.h * k; + const Lp = [], Rp = []; + for (let i = 0; i <= 20; i++) { const k = i / 20; Lp.push([T.x - half(k), yAt(k)]); Rp.push([T.x + half(k), yAt(k)]); } + const body = [...Lp, ...Rp.slice().reverse()]; + print(c, body, paper); + // stripes, seen from below: their edges curve up a little + const bands = 6, sag = T.wB * 0.09; + const edge = k => { const hw = half(k), out = []; for (let i = 0; i <= 12; i++) { const f = i / 12 * 2 - 1; out.push([T.x + f * hw, yAt(k) - sag * (1 - f * f)]); } return out; }; + let n = 0; + for (let j = 1; j < bands; j += 2) { + const k0 = j / bands, k1 = (j + 1) / bands; + const lo = edge(k0), hi = j + 1 >= bands ? [[T.x + half(1), yAt(1)], [T.x - half(1), yAt(1)]] : edge(k1).reverse(); + const col = hex(th.stripes[n++ % th.stripes.length]); + print(c, [...lo, ...hi], mul(col, P.paper)); + } + // windows and the door + const win = (k, w, h) => { const y = yAt(k); return [[T.x - w / 2, y - h / 2], [T.x + w / 2, y - h / 2], [T.x + w / 2, y + h / 2], [T.x - w / 2, y + h / 2]]; }; + const night = smooth(0.45, 0.75, s.d); + const glass = night > 0.5 ? P.beam : rgba(C.roof); + for (const k of [0.25, 0.58]) { const q = win(k, T.wB * 0.22, T.er * 0.75); poly(c, q); c.fillStyle = typeof glass === 'string' ? glass : rgba(glass); c.fill(); } + const dw = T.wB * 0.32, dh = T.wB * 0.5, dpts = [[T.x - dw / 2, T.base], ...ellipse(T.x, T.base - dh + dw / 2, dw / 2, dw / 2, 10, Math.PI, TAU).map(p => p), [T.x + dw / 2, T.base]]; + poly(c, dpts); c.fillStyle = rgba(C.roof); c.fill(); + // the outline: two sides and the base, then a few stripe edges and hatches on the shady side + c.beginPath(); + ribbon(c, dens([[T.x - T.wB / 2 - 1 * u, T.base + 3 * u], [T.x - T.wT / 2, T.top - 1 * u]], 6), SIL, 31, { tin: 6, tout: 4, w0: 0.7, w1: 0.6, outline: true }); + ribbon(c, dens([[T.x + T.wB / 2 + 0.5 * u, T.base + 2 * u], [T.x + T.wT / 2, T.top - 1 * u]], 6), SIL, 33, { tin: 6, tout: 4, w0: 0.7, w1: 0.6, outline: true }); + for (let j = 1; j < bands; j++) { + const e = edge(j / bands), cut = Math.round(e.length * (0.55 + hash(j) * 0.35)); + ribbon(c, j % 2 ? e.slice(0, cut) : e.slice(e.length - cut), TEX, 40 + j, { tin: 2, tout: 8, w0: 0.6, w1: 0.1 }); + } + for (let j = 0; j < bands; j++) { + const k = (j + 0.5) / bands, x = T.x + half(k) - 2.6 * u, y = yAt(k); + for (let i = 0; i < 3; i++) ribbon(c, [[x - i * 3 * u, y - T.h / bands * 0.32], [x - i * 3 * u - 0.6 * u, y + T.h / bands * 0.3 - i * 2 * u]], TEX * 0.85, j * 3 + i, { tin: 2, tout: 2, amp: 0.3, hatch: true }); + } + for (const k of [0.25, 0.58]) edges(c, win(k, T.wB * 0.22, T.er * 0.75), TEX, 50 + k * 10, true, { ov: 0.6 }); + ribbon(c, dpts, DET * 0.9, 61, { tin: 2, tout: 2, outline: true }); + c.fillStyle = INK; c.fill(); + // the gallery: a dark deck the eye sits on + const gw = T.wT * 0.95, gy = T.top; + const deck = [[T.x - gw, gy + 1 * u], [T.x - gw * 1.05, gy - T.gal * 0.42], [T.x + gw * 1.05, gy - T.gal * 0.42], [T.x + gw, gy + 1 * u]]; + poly(c, deck); c.fillStyle = INK; c.fill(); +} + +/* the eye in the lantern: an iris the colour of the light, a pupil that follows the beam (or the ball), a lid. + It also squints when up to something, goes wide when caught, shuts happily when it got away with it, and squeezes + shut, trembling, when it strains to light its lamp. */ +function drawEye(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, L = g.lamp, er = T.er, B = s.beam; + const night = smooth(0.35, 0.8, s.d), md = s.mood() || {}, wide = md.wide || 0, sq = md.squint || 0, strain = md.strain || 0; + const rx = er * 1.32 * (1 + 0.13 * wide), ry = er * 1.08 * (1 + 0.16 * wide); + const eye = ellipse(L.x, L.y, rx, ry, 40); + c.save(); c.translate(Math.sin(s.t * 97) * 1.1 * u * strain, 0); + print(c, eye, mix(mul(hex('#FFFFFF'), P.paper), P.beam, night * 0.25)); + // where it looks: along the beam, or straight at you when the light faces you, or wherever it's busy + let look = clamp(B.len * 1.3) * (1 - B.flash), pa = B.a; + if (md.a != null) { pa = md.a; look = md.m; } + const px = L.x + Math.cos(pa) * rx * 0.42 * look, py = L.y + Math.sin(pa) * ry * 0.36 * look; + const ir = er * (0.62 + 0.12 * B.flash) * (1 - 0.2 * wide), pr = er * (0.3 - 0.08 * night + 0.1 * B.flash) * (1 - 0.42 * wide); + c.save(); poly(c, eye, true, REGX, REGY); c.clip(); + dot(c, px + REGX, py + REGY, ir, mix(P.beam, C.iris, 0.35 - 0.2 * night)); + c.restore(); + c.save(); poly(c, eye); c.clip(); + dot(c, px, py, pr, INK); + dot(c, px - pr * 0.45, py - pr * 0.5, pr * 0.38 + 0.4, '#FFFFFF'); + c.beginPath(); ribbon(c, ellipse(px, py, ir, ir, 22), TEX, 3, { tin: 1, tout: 1, w0: 1, w1: 1, amp: 0.3, outline: true }); c.fillStyle = INK; c.fill(); + // the lid: a little heavy by day, wide awake at night, shut when it blinks or winks + const bl = s.t - s.blink.at, wk = s.t - s.blink.wink; + let shut = lerp(lerp(0.2, 0.02, night), 0, wide); + if (md.shut) shut = Math.max(shut, md.shut); + shut = Math.max(shut, 0.5 * sq); + if (bl < 0.18 && !s.bore) shut = Math.max(shut, Math.sin(bl / 0.18 * Math.PI)); + if (wk < 0.6) shut = Math.max(shut, wk < 0.1 ? wk / 0.1 : wk < 0.45 ? 1 : 1 - (wk - 0.45) / 0.15); + if (s.reduced && wk >= 0.6) shut = lerp(0.2, 0.02, night); + shut = Math.max(shut, s.drowsy()); + const lidCol = rgba(mul(hex('#FFFFFF'), P.paper)); + if (md.happy) { + // shut, and pleased with itself: the lid line curves up + poly(c, eye); c.fillStyle = lidCol; c.fill(); + c.beginPath(); ribbon(c, ellipse(L.x, L.y + ry * 0.38, rx * 0.72, ry * 0.62, 16, 1.18 * Math.PI, 1.82 * Math.PI), DET, 5, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); + } else if (shut > 0.86 && strain > 0.5) { + // squeezed shut, straining: a tight, crumpled line + poly(c, eye); c.fillStyle = lidCol; c.fill(); + c.beginPath(); + ribbon(c, [[-0.88, 0.02], [-0.44, 0.13], [0, 0.03], [0.44, 0.13], [0.88, 0.02]].map(([x, y]) => [L.x + x * rx, L.y + y * ry]), DET, 5, { tin: 3, tout: 3 }); + c.fillStyle = INK; c.fill(); + } else if (shut > 0.86) { + poly(c, eye); c.fillStyle = lidCol; c.fill(); + c.beginPath(); + ribbon(c, ellipse(L.x, L.y - ry * 0.35, rx * 0.78, ry * 0.55, 16, 0.2 * Math.PI, 0.8 * Math.PI), DET, 5, { tin: 3, tout: 3 }); + for (const k of [0.32, 0.5, 0.68]) { const a = k * Math.PI, ex = L.x + Math.cos(a) * rx * 0.78, ey = L.y - ry * 0.35 + Math.sin(a) * ry * 0.55; ribbon(c, [[ex, ey], [ex + Math.cos(a) * er * 0.35, ey + Math.sin(a) * er * 0.35]], TEX, k * 9, { tin: 1, tout: 1 }); } + c.fillStyle = INK; c.fill(); + } else if (shut > 0.01) { + const ly = L.y - ry + shut * ry * 1.2, lid = []; + for (let i = 0; i <= 24; i++) { const a = Math.PI + i / 24 * Math.PI; lid.push([L.x + Math.cos(a) * (rx + 2), L.y + Math.sin(a) * (ry + 2)]); } + for (let i = 0; i <= 16; i++) { const f = 1 - i / 16, x = L.x - rx + f * 2 * rx; lid.push([x, ly + (1 - ((x - L.x) / rx) ** 2) * ry * 0.22 * shut]); } + poly(c, lid); c.fillStyle = lidCol; c.fill(); + c.beginPath(); ribbon(c, lid.slice(25), DET, 5, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); + } + if (sq > 0.01 && !md.happy) { + // squinting: the lower lid comes up too + const ly = L.y + ry - sq * ry * 0.62, lid = []; + for (let i = 0; i <= 24; i++) { const a = i / 24 * Math.PI; lid.push([L.x + Math.cos(a) * (rx + 2), L.y + Math.sin(a) * (ry + 2)]); } + for (let i = 0; i <= 16; i++) { const x = L.x - rx + i / 16 * 2 * rx; lid.push([x, ly - (1 - ((x - L.x) / rx) ** 2) * ry * 0.16 * sq]); } + poly(c, lid); c.fillStyle = lidCol; c.fill(); + c.beginPath(); ribbon(c, lid.slice(25), TEX * 1.2, 6, { tin: 3, tout: 3 }); c.fillStyle = INK; c.fill(); + } + c.restore(); + c.beginPath(); loop(c, eye, SIL * 0.85, 8); c.fillStyle = INK; c.fill(); + // caught: a start, in a few short strokes either side (the same strokes when it strains) + const marks = Math.max(wide, strain); + if (marks > 0.05) { + c.beginPath(); + for (const sx of [-1, 1]) for (const k of [-0.42, 0, 0.42]) { + const a = (sx < 0 ? Math.PI : 0) + sx * k, r0 = rx * 1.22, r1 = r0 + er * 0.55 * marks; + ribbon(c, [[L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0 * 0.9], [L.x + Math.cos(a) * r1, L.y + Math.sin(a) * r1 * 0.9]], TEX * 1.1, 20 + k * 7 + sx, { tin: 1, tout: 2 }); + } + c.fillStyle = INK; c.fill(); + } + c.restore(); + // the gallery's railing passes in front of the eye + const gw = T.wT * 0.95, top = T.top - T.gal * 0.42, rail = top - T.gal * 0.62; + c.beginPath(); + ribbon(c, dens([[L.x - gw * 1.08, rail], [L.x + gw * 1.08, rail]], 4), DET * 0.9, 9, { tin: 2, tout: 2, w0: 0.8, w1: 0.8 }); + for (let i = 0; i <= 6; i++) { const x = L.x - gw + i / 6 * gw * 2; ribbon(c, [[x, top], [x, rail]], TEX * 0.95, 70 + i, { tin: 1, tout: 1, w0: 0.9, w1: 0.9, amp: 0.2 }); } + c.fillStyle = INK; c.fill(); +} + +/* The hat right now: where its brim sits and where the cone's tip and the bobble are. It lifts and + flops when it heads the ball (the ball game reads the bobble's place from here too). */ +function hatGeom(s) { + const g = s.g, L = g.lamp, er = g.lh.er, ry = er * 1.08, ka = s.t - s.hatKick, kicked = !s.reduced && ka >= 0 && ka < 0.7; + const lift = kicked && ka < 0.3 ? Math.sin(ka / 0.3 * Math.PI) * er * 0.34 : 0; + const flop = kicked ? Math.sin(ka * 19) * Math.exp(-ka * 6) * 0.34 : 0; + const sway = s.reduced ? 0.15 : 0.15 + s.windTotal * 0.25 + Math.sin(s.t * 1.3) * 0.05 + flop; + const by = L.y - ry * 0.62 - lift, pr = er * 0.66; + const tip = [L.x + er * (0.5 + sway * 2.2), by - er * 3.1 + Math.abs(sway) * er * 0.6]; + return { by, bw: er * 1.5, bh: er * 0.62, sway, tip, pr, bob: [tip[0], tip[1] - pr * 0.6] }; +} +/* a pom-pom: a scribbled ball of wool, turning with `spin` */ +function bobble(c, x, y, pr, col, spin = 0) { + const pb = []; + for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = pr * (1 + 0.09 * Math.abs(Math.sin((a - spin) * 5.5 + 1))); pb.push([x + Math.cos(a) * r, y + Math.sin(a) * r]); } + shape(c, pb, col, DET * 0.95, 16); + const cs = Math.cos(spin), sn = Math.sin(spin), ox = pr * 0.1, oy = pr * 0.1; + c.beginPath(); ribbon(c, ellipse(x + ox * cs - oy * sn, y + ox * sn + oy * cs, pr * 0.42, pr * 0.36, 8, 0.2 + spin, 1.9 + spin), TEX * 0.8, 90, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); +} + +/* a knitted pom-pom hat: a ribbed brim, a long floppy cone in the theme's colour, a white bobble */ +function drawHat(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, L = g.lamp, er = T.er, th = THEMES[g.theme || s.theme]; + const col = mul(hex(th.hat), P.paper), h = hatGeom(s), { by, bw, bh, sway, tip, pr } = h; + const cone = curve([[L.x - bw * 0.92, by - bh * 0.6], [L.x - bw * 0.5, by - er * 1.9], [L.x + er * 0.1 + sway * er, by - er * 2.85], tip, [L.x + er * 0.65 + sway * er * 1.4, by - er * 2.2], [L.x + bw * 0.9, by - bh * 0.6]], 3); + print(c, cone, col); + // a white knitted stripe across the cone + c.save(); poly(c, cone, true, REGX, REGY); c.clip(); + const sy = by - er * 1.55; + c.beginPath(); c.moveTo(L.x - bw * 2, sy + er * 0.25); c.quadraticCurveTo(L.x, sy - er * 0.05, L.x + bw * 2, sy + er * 0.2); c.lineTo(L.x + bw * 2, sy - er * 0.25); c.quadraticCurveTo(L.x, sy - er * 0.55, L.x - bw * 2, sy - er * 0.25); c.closePath(); + c.fillStyle = rgba(mul(hex('#FFFFFF'), P.paper)); c.fill(); + c.restore(); + const brim = [[L.x - bw, by + bh * 0.45], [L.x - bw * 1.02, by - bh * 0.55], [L.x + bw * 1.02, by - bh * 0.6], [L.x + bw, by + bh * 0.5]]; + print(c, brim, mix(col, hex('#000000'), 0.12)); + c.beginPath(); + loop(c, cone, DET * 1.05, 14, { over: 0.05 }); + edges(c, brim, DET, 15, true, { ov: 1.2 }); + for (let i = 1; i < 7; i++) { const x = L.x - bw + i / 7 * bw * 2; ribbon(c, [[x, by - bh * 0.4], [x + 0.4 * u, by + bh * 0.32]], TEX * 0.8, 80 + i, { tin: 1, tout: 1, amp: 0.2 }); } + c.fillStyle = INK; c.fill(); + // the bobble, unless it's in play; then a few loose ends of wool, until a new one grows back with a pop + const k = s.bobbleK(); + if (k < 0.999) { + c.beginPath(); + for (const a of [-2.3, -1.6, -0.9]) ribbon(c, [[tip[0], tip[1]], [tip[0] + Math.cos(a) * pr * 0.55, tip[1] + Math.sin(a) * pr * 0.55]], TEX, a * 3, { tin: 1, tout: 2 }); + c.fillStyle = INK; c.fill(); + } + if (k > 0.02) bobble(c, tip[0], tip[1] - pr * 0.6 * k, pr * k, mul(hex('#FFFFFF'), P.paper)); + const ra = s.t - s.regrowAt; + if (ra > 0.18 && ra < 0.75 && !s.reduced) { + const f = (ra - 0.18) / 0.57, cx = tip[0], cy = tip[1] - pr * 0.6; + c.beginPath(); + for (let i = 0; i < 6; i++) { const a = -Math.PI / 2 + (i - 2.5) * 0.62, r0 = pr * (1.35 + f * 0.9), r1 = r0 + pr * 0.55 * (1 - f * 0.6); ribbon(c, [[cx + Math.cos(a) * r0, cy + Math.sin(a) * r0], [cx + Math.cos(a) * r1, cy + Math.sin(a) * r1]], TEX, i, { tin: 1, tout: 1 }); } + c.fillStyle = rgba(P.ink, 1 - f); c.fill(); + } +} + +/* grass along the ridge, leaning with the wind and away from the pointer */ +function drawTufts(s, c) { + const g = s.g, u = g.u; + c.beginPath(); + for (const b of g.tufts) { + let a = s.reduced ? 0.12 : Math.sin(s.t * 1.6 + b.p) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (s.ptr.on && !s.reduced) { + const dx = b.x - s.ptr.x, dy = b.y - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; + if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); + } + a = clamp(a, -1.3, 1.3); + for (let k = 0; k < b.n + 1; k++) { + const da = (k - b.n / 2) * 0.42, l = b.l * (k === 1 ? 1.15 : 0.85), x = b.x + (k - b.n / 2) * 1.6 * u, y = b.y + 1.5 * u; + const tx = x + Math.sin(a + da) * l, ty = y - Math.cos(a + da) * l, w = 1.1 * g.pw; + c.moveTo(x - w, y); c.quadraticCurveTo(lerp(x, tx, 0.5) - w * 0.4, lerp(y, ty, 0.55), tx, ty); c.quadraticCurveTo(lerp(x, tx, 0.5) + w * 0.4, lerp(y, ty, 0.55), x + w, y); c.closePath(); + } + } + c.fillStyle = INK; c.fill(); +} + +/* a tuft of fresh grass on the hill: blades that rise out of the ground one after another, lean with the wind + and away from the pointer, part for an animal walking through, and go one by one when something eats them */ +function drawMeadow(s, c, m) { + const g = s.g, u = g.u, P = s.P, t = s.t, rm = s.reduced, age = t - m.t0; + // a section's props stand in front of the grass (the huts, the desk) + let alpha = 1; + if (s.pres.tiling > 0.01) for (const q of hutSpots(s)) if (Math.abs(m.x - q.x) < 34 * u && m.y > q.y - 64 * u && m.y < q.y + 8 * u) alpha = Math.min(alpha, 1 - s.pres.tiling); + if (s.pres.widgets > 0.01) { const d = deskSpot(s); if (Math.abs(m.x - d.x) < 56 * u && m.y > d.y - 100 * u && m.y < d.y + 8 * u) alpha = Math.min(alpha, 1 - s.pres.widgets); } + if (alpha <= 0.01) return; + // the lean: the wind and its gusts, away from the pointer, apart for a passer-by, toward the mouth eating it + let a = rm ? 0.12 : Math.sin(t * 1.6 + m.ph) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (!rm) { + if (s.ptr.on) { const dx = m.x - s.ptr.x, dy = m.y - 6 * u - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); } + for (const o of s.objs) { + if (!grazer(o) || o === m.by || o.state !== 'land') continue; + const dx = m.x - o.x, w = 19 * u * o.sc; + if (Math.abs(dx) < w && Math.abs(m.y - o.y) < 9 * u * o.sc) a += Math.sign(dx || 1) * 0.95 * (1 - Math.abs(dx) / w); + } + const e = m.by; + if (e && e.act === 'eat') a += (e.flip ? 1 : -1) * (0.2 + 0.16 * Math.sin(t * 9 + e.seed)) * e.head; + } + const blades = []; + for (const b of m.blades) { + const gk = rm ? 1 : pop((age - b.d) / 0.75), ck = b.cut == null ? 1 : 1 - clamp((t - b.cut) / 0.4) ** 2; + if (gk <= 0.02 || ck <= 0.03) continue; + const L = b.l * gk * ck, th = clamp(b.a + a * b.flex, -1.45, 1.45), bx = m.x + b.dx, by = m.y + 1.2 * u; + const mx = bx + Math.sin(th * 0.4 + b.a * 0.3) * L * 0.55, my = by - Math.cos(th * 0.4) * L * 0.55, tx = bx + Math.sin(th) * L, ty = by - Math.cos(th) * L; + const pts = []; + for (let i = 0; i <= 6; i++) { const q = i / 6, r = 1 - q; pts.push([r * r * bx + 2 * r * q * mx + q * q * tx, r * r * by + 2 * r * q * my + q * q * ty]); } + blades.push({ pts, w: (3.9 + 0.6 * ck) * u * m.sc, L, gk: clamp(gk) }); + } + if (!blades.length) return; + c.save(); c.globalAlpha = alpha; + // the green, printed off the line + const night = smooth(0.4, 0.9, s.d); + c.beginPath(); + for (const { pts, w } of blades) { + const l = [], r = [], n = pts.length - 1; + for (let i = 0; i <= n; i++) { + const p0 = pts[Math.max(0, i - 1)], p1 = pts[Math.min(n, i + 1)], dx = p1[0] - p0[0], dy = p1[1] - p0[1], dl = Math.hypot(dx, dy) || 1, hw = w * (1 - i / n) * 0.5 + 0.25; + l.push([pts[i][0] - dy / dl * hw + REGX, pts[i][1] + dx / dl * hw + REGY]); r.push([pts[i][0] + dy / dl * hw + REGX, pts[i][1] - dx / dl * hw + REGY]); + } + const ring = [...l, ...r.reverse()]; + c.moveTo(ring[0][0], ring[0][1]); + for (const p of ring) c.lineTo(p[0], p[1]); + c.closePath(); + } + c.fillStyle = rgba(mix(P.hill2, C.sprout, 0.85 * (1 - night))); c.fill(); + // the pen: one stroke a blade, and a little ground under the tuft that grows and goes with it + c.beginPath(); + blades.forEach(({ pts, L }, i) => ribbon(c, pts, TEX * (L > 6 * u ? 0.8 : 0.65), m.ph * 3 + i, { tin: 1, tout: 6, w0: 0.9, w1: 0.12, amp: 0.25 })); + const gw = 9 * u * m.sc * Math.min(1, Math.max(...blades.map(b => b.gk)) * 1.4) * Math.max(0.4, blades.length / m.blades.length); + ribbon(c, [[m.x - gw, m.y + 2.2 * u], [m.x - gw * 0.3, m.y + 1 * u], [m.x + gw * 0.4, m.y + 1.2 * u], [m.x + gw, m.y + 2.4 * u]], TEX, m.ph, { tin: 2, tout: 3, amp: 0.3 }); + c.fillStyle = INK; c.fill(); + c.restore(); +} + +function drawSmoke(s, c) { + if (s.reduced) return; + const g = s.g, u = g.u, k = g.cot, x0 = k.x - k.w / 2 + k.w * 0.68 + k.w * 0.075, y0 = k.base - k.h - k.h * 1.5; + c.beginPath(); + for (let i = 0; i < 3; i++) { + const a = (s.t * 0.22 + i / 3) % 1, sx = x0 + s.windTotal * 50 * u * a + Math.sin(a * 6 + i) * 3 * u, sy = y0 - 6 * u - a * 46 * u, r = (2.5 + a * 6) * u; + const sp = []; + for (let k2 = 0; k2 <= 16; k2++) { const an = k2 / 16 * TAU * 1.35 + i, rr = r * (0.25 + k2 / 16 * 0.75); sp.push([sx + Math.cos(an) * rr, sy + Math.sin(an) * rr]); } + ribbon(c, sp, TEX * (1 - a * 0.4), i, { tin: 3, tout: 6 }); + } + c.fillStyle = rgba(s.P.ink, 0.85); c.fill(); +} + +/* ---------- scene states: one dry gag per section, drawn where it's seen ---------- */ +function drawStates(s, c) { + const p = s.pres; + if (p.themes > 0.01) DRAW.washing(s, c, p.themes); + if (p.tiling > 0.01) DRAW.huts(s, c, p.tiling); + if (p.widgets > 0.01) DRAW.desk(s, c, p.widgets); + if (p.install > 0.01) DRAW.doorway(s, c, p.install); + if (p.edit > 0.01) DRAW.editKeeper(s, c, p.edit); +} +/* pops a prop in from the ground: scale with a little overshoot */ +const pop = p => { const t = clamp(p); return t < 1 ? 1 - Math.pow(1 - t, 3) * Math.cos(t * 4.2) : 1; }; + +function drawKeeper(s, c, x, y, sc, pose, flip, walk) { + const u = s.g.u * sc, P = s.P, f = flip ? -1 : 1; + const navy = mul(C.navy, P.paper), skin = mul(C.skin, P.paper), white = mul(hex('#FFFFFF'), P.paper); + c.save(); c.translate(x, y); c.scale(f, 1); + // legs + const st = Math.sin(walk) * 0.45; + c.beginPath(); + ribbon(c, [[-2 * u, -12 * u], [-2 * u + Math.sin(st) * 9 * u, 0]], DET * 1.5, 1, { tin: 1, tout: 1, w0: 1, w1: 1 }); + ribbon(c, [[2 * u, -12 * u], [2 * u - Math.sin(st) * 9 * u, 0]], DET * 1.5, 2, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = rgba(navy); c.fill(); + // boots + dot(c, -2 * u + Math.sin(st) * 9 * u + 1.5 * u, -0.5 * u, 2.2 * u, INK); dot(c, 2 * u - Math.sin(st) * 9 * u + 1.5 * u, -0.5 * u, 2.2 * u, INK); + // a striped jumper + const body = [[-5.5 * u, -11 * u], [-6 * u, -24 * u], [6 * u, -24 * u], [5.5 * u, -11 * u]]; + print(c, body, white); + c.save(); poly(c, body, true, REGX, REGY); c.clip(); + for (let i = 0; i < 3; i++) { c.fillStyle = rgba(navy); c.fillRect(-8 * u + REGX, (-22 + i * 4.2) * u + REGY, 16 * u, 2.1 * u); } + c.restore(); + c.beginPath(); edges(c, body, DET, 3, true, { ov: 0.6 }); + // arms + if (pose === 'carry') { + ribbon(c, [[-5 * u, -22 * u], [-7 * u, -30 * u], [-5 * u, -37 * u]], DET * 1.1, 4, { tin: 1, tout: 1 }); + ribbon(c, [[5 * u, -22 * u], [7 * u, -30 * u], [5 * u, -37 * u]], DET * 1.1, 5, { tin: 1, tout: 1 }); + } else if (pose === 'wave') { + const wv = s.reduced ? 0 : Math.sin(s.t * 7) * 0.5; + ribbon(c, [[-5 * u, -22 * u], [-7 * u, -15 * u]], DET * 1.1, 4, { tin: 1, tout: 1 }); + ribbon(c, [[5 * u, -22 * u], [10 * u, -28 * u], [10 * u + Math.sin(wv) * 7 * u, -36 * u]], DET * 1.1, 5, { tin: 1, tout: 1 }); + } else { + ribbon(c, [[-5 * u, -22 * u], [-6.5 * u - st * 4 * u, -14 * u]], DET * 1.1, 4, { tin: 1, tout: 1 }); + ribbon(c, [[5 * u, -22 * u], [6.5 * u + st * 4 * u, -14 * u]], DET * 1.1, 5, { tin: 1, tout: 1 }); + } + c.fillStyle = INK; c.fill(); + if (pose === 'back') { + // seen from behind: white hair under the cap, his ears, no face + const head = ellipse(0, -29 * u, 5 * u, 5.4 * u, 20); + for (const sx of [-1, 1]) dot(c, sx * 5 * u, -28.6 * u, 1.3 * u, skin); + print(c, head, white); + c.beginPath(); loop(c, head, DET, 6, { over: 0.04 }); c.fillStyle = INK; c.fill(); + const back = [[-5.2 * u, -31.5 * u], [-4.8 * u, -36.2 * u], [4.8 * u, -36.2 * u], [5.2 * u, -31.5 * u]]; + poly(c, back); c.fillStyle = rgba(navy); c.fill(); + c.beginPath(); edges(c, back, DET * 0.9, 8, false, { ov: 0.5 }); c.fillStyle = INK; c.fill(); + c.restore(); + return; + } + // head, white beard, cap with a peak + const head = ellipse(0.5 * u, -29 * u, 5 * u, 5.4 * u, 20); + print(c, head, skin); + const beard = [[-4.4 * u, -29 * u], [-3 * u, -23.5 * u], [1 * u, -22.4 * u], [5.2 * u, -24.5 * u], [5.6 * u, -28.5 * u]]; + print(c, curve(beard, 2), white); + c.beginPath(); loop(c, head, DET, 6, { over: 0.04 }); ribbon(c, curve(beard, 2), TEX, 7, { tin: 1, tout: 1, outline: true }); c.fillStyle = INK; c.fill(); + dot(c, 2.6 * u, -30 * u, 0.9 * u + 0.3, INK); + dot(c, 5.6 * u, -28.6 * u, 1.3 * u, mix(skin, hex('#E0786A'), 0.5)); + const cap = [[-5 * u, -31.5 * u], [-4.6 * u, -36 * u], [3 * u, -36.4 * u], [5.4 * u, -32 * u]]; + poly(c, cap); c.fillStyle = rgba(navy); c.fill(); + c.beginPath(); edges(c, cap, DET * 0.9, 8, false, { ov: 0.5 }); ribbon(c, [[3 * u, -32 * u], [9 * u, -31 * u]], DET * 1.2, 9, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); + c.restore(); +} + +function drawTile(s, c, x, y, u) { + const w = 34 * u, h = 15 * u, r = 5 * u, P = s.P; + const box = []; + for (const [cx, cy, a] of [[x + w / 2 - r, y - h / 2 + r, -Math.PI / 2], [x + w / 2 - r, y + h / 2 - r, 0], [x - w / 2 + r, y + h / 2 - r, Math.PI / 2], [x - w / 2 + r, y - h / 2 + r, Math.PI]]) + for (let i = 0; i <= 4; i++) { const an = a + i / 4 * Math.PI / 2; box.push([cx + Math.cos(an) * r, cy + Math.sin(an) * r]); } + shape(c, box, C.tile, DET, 4, { over: 0.05 }); + label(c, '10:30', x + REGX * 0.5, y + REGY * 0.5 + 0.5, 10 * u + 1.5, rgba(mul(hex('#FFFFFF'), P.paper)), 'center', 0); +} + +/* edit: the keeper wanders the ridge with a widget tile ("10:30") */ +function drawEditKeeper(s, c, p) { + const g = s.g, u = g.u, T = g.lh, k = g.cot, sc = g.portrait ? 1.5 : 1.55; + const t = s.t - s.since.edit; + const door = k.x - k.w * 0.5 + k.w * 0.68, xL = g.portrait ? g.W * 0.42 : g.W * 0.668, xR = T.x - T.wB * 1.6; + const v = 32 * u; + let x, flip; + const out = (door - xR) / v; + if (s.reduced) { x = lerp(xL, xR, 0.45); flip = true; } + else if (t < out) { x = door - t * v; flip = true; } + else { + const span = xR - xL, ph = ((t - out) * v) % (span * 2 + 120 * u); + if (ph < span) { x = xR - ph; flip = true; } + else if (ph < span + 60 * u) { x = xL; flip = false; } + else if (ph < span * 2 + 60 * u) { x = xL + (ph - span - 60 * u); flip = false; } + else { x = xR; flip = true; } + } + const ry = s.ridgeY(x) ?? k.base, walking = !s.reduced && t > 0; + c.globalAlpha = clamp(p * 1.5); + DRAW.keeper(s, c, x, ry + 2 * u, sc, 'carry', flip, walking ? s.t * 9 : 0); + DRAW.tile(s, c, x + (flip ? -1 : 1) * 0.5 * u, ry + 2 * u - 42 * u * sc + Math.sin(s.reduced ? 0 : s.t * 9) * 0.6 * u, u * sc); + c.globalAlpha = 1; +} + +/* someone knocked at the cottage (see knock and VISIT): the door jumps under each knock, and at the third swings open + on a lamp-lit room; the keeper looks out to the left and to the right, sees nobody, turns his back, walks in and shuts it */ +function drawVisit(s, c) { + const v = s.visit, g = s.g, k = g.cot, d = k.door; + if (!d) return; + const u = g.u, P = s.P, a = v ? s.t - v.t0 : -1, rm = s.reduced, V = VISIT, sc = g.portrait ? 1.05 : 1.45, ka = s.t - s.knocks.at; + const open = !v || v.away ? 0 : rm ? (a >= V.open[0] && a < V.shut[1] ? 1 : 0) : smooth(V.open[0], V.open[1], a) * (1 - smooth(V.shut[0], V.shut[1], a)); + // each knock jolts it + const jig = rm || ka > 0.45 ? 0 : Math.sin(ka * 55) * 0.9 * u * (1 - ka / 0.45); + if (open < 0.005 && jig === 0) return; + const night = smooth(0.45, 0.75, s.d); + const frame = [[d.x, k.base], [d.x, d.y], [d.x + d.w, d.y], [d.x + d.w, k.base]]; + if (open < 0.005) { + // shut and jumping: the wall where the door was, under it + poly(c, [[d.x - 2 * u, k.base], [d.x - 2 * u, d.y - 2 * u], [d.x + d.w + 2 * u, d.y - 2 * u], [d.x + d.w + 2 * u, k.base]]); + c.fillStyle = rgba(mul(hex('#FFFFFF'), P.paper)); c.fill(); + } else { + // inside, the lamp: the doorway warm, and at night its light spilling onto the grass + if (night > 0.05) { + c.globalAlpha = open * night; + poly(c, [[d.x, k.base], [d.x + d.w, k.base], [d.x + d.w + 44 * u, k.base + 34 * u], [d.x - 34 * u, k.base + 34 * u]]); + c.fillStyle = rgba(mix(P.hill, C.lamp, 0.62)); c.fill(); + c.globalAlpha = 1; + } + poly(c, frame); c.fillStyle = rgba(C.lamp); c.fill(); + // the keeper in the doorway, leaning out the way he looks; then his back, walking in, a little smaller and higher + // the further in he is, into the lamplight + const kin = rm ? (a >= V.keeper[0] && a < V.keeper[1] ? 1 : 0) : smooth(V.keeper[0], V.keeper[0] + 0.12, a) * (1 - smooth(V.keeper[1] - 0.14, V.keeper[1], a)); + if (kin > 0.01) { + const right = a >= V.right, back = a >= V.turn, deep = rm ? 0 : smooth(V.turn + 0.1, V.keeper[1], a); + const lean = rm ? 0 : (right ? 1 : -1) * 2.5 * u * sc * (1 - smooth(V.turn - 0.05, V.turn + 0.15, a)); + c.globalAlpha = kin; + DRAW.keeper(s, c, d.x + d.w / 2 + lean, k.base - 5 * u * sc * deep, sc * (1 - 0.15 * deep), back ? 'back' : 'stand', !right && !back, back && !rm ? s.t * 9 : 0); + c.globalAlpha = 1; + } + } + // the door, swinging out on its right-hand hinge until it lies against the wall + drawDoor(s, c, open, jig); +} + +/* install: night, the cottage door open with warm light, the keeper waving */ +function drawDoorway(s, c, p) { + const g = s.g, u = g.u, P = s.P, k = g.cot, d = k.door; + if (!d) return; + c.globalAlpha = clamp(p * 1.4); + // light spilling onto the grass + const spill = [[d.x, k.base], [d.x + d.w, k.base], [d.x + d.w + 44 * u, k.base + 34 * u], [d.x - 34 * u, k.base + 34 * u]]; + poly(c, spill); c.fillStyle = rgba(mix(P.hill, C.lamp, 0.62)); c.fill(); + poly(c, [[d.x, k.base], [d.x, d.y], [d.x + d.w, d.y], [d.x + d.w, k.base]]); c.fillStyle = rgba(C.lamp); c.fill(); + // the door swung open against the wall + const door = [[d.x + d.w, k.base], [d.x + d.w, d.y], [d.x + d.w * 1.55, d.y + 2 * u], [d.x + d.w * 1.55, k.base - 1 * u]]; + print(c, door, mul(C.door, P.paper)); + c.beginPath(); edges(c, door, DET * 0.85, 3, true, { ov: 0.6 }); c.fillStyle = INK; c.fill(); + DRAW.keeper(s, c, d.x + d.w * 0.5, k.base, g.portrait ? 1.05 : 1.45, 'wave', false, 0); + c.globalAlpha = 1; +} + +/* widgets: a sheep at a tiny desk with a laptop, typing */ +function drawDesk(s, c, p) { + const g = s.g, P = s.P, sc = pop(p) * (g.portrait ? 1.75 : 2.1), u = g.u * sc; + const { x, y } = deskSpot(s); + if (sc < 0.05) return; + const typing = s.reduced ? 0 : s.t * 11, wool = mul(C.wool, P.paper), night = smooth(0.4, 0.8, s.d); + c.save(); c.translate(x, y); + // the sheep behind the desk, seen from the front + const bob = s.reduced ? 0 : Math.sin(s.t * 2.4) * 0.6 * u; + const body = []; + for (let i = 0; i < 36; i++) { const a = i / 36 * TAU, r = 1 + 0.12 * Math.abs(Math.sin(a * 5)); body.push([Math.cos(a) * 15 * u * r, -27 * u + Math.sin(a) * 12 * u * r]); } + shape(c, body, wool, DET, 3); + const face = ellipse(0, -36 * u + bob, 6 * u, 7.5 * u, 20); + poly(c, face); c.fillStyle = INK; c.fill(); + for (const sx of [-1, 1]) { const ear = ellipse(sx * 8.5 * u, -40 * u + bob, 4.4 * u, 1.9 * u, 12, 0, TAU, sx * 0.35); poly(c, ear); c.fillStyle = INK; c.fill(); } + const tuft = []; for (let i = 0; i < 14; i++) { const a = Math.PI + i / 13 * Math.PI, r = 1 + 0.2 * Math.abs(Math.sin(a * 4)); tuft.push([Math.cos(a) * 6.5 * u * r, -42 * u + bob + Math.sin(a) * 3.6 * u * r]); } + shape(c, tuft, wool, TEX, 5); + // eyes on the screen, lit by it at night + for (const sx of [-1, 1]) { dot(c, sx * 2.6 * u, -36.5 * u + bob, 1.6 * u, '#FFFFFF'); dot(c, sx * 2.6 * u, -35.8 * u + bob, 0.8 * u, INK); } + // the desk + const top = [[-24 * u, -14 * u], [24 * u, -14 * u], [24 * u, -10.5 * u], [-24 * u, -10.5 * u]]; + print(c, top, mul(C.wood, P.paper)); + c.beginPath(); edges(c, top, DET, 7, true, { ov: 1 }); + ribbon(c, [[-20 * u, -10.5 * u], [-21 * u, 0]], DET, 8, { tin: 1, tout: 1 }); ribbon(c, [[20 * u, -10.5 * u], [21 * u, 0]], DET, 9, { tin: 1, tout: 1 }); + c.fillStyle = INK; c.fill(); + // the laptop, seen from the back, with a lighthouse sticker; the screen's glow on the sheep + if (night > 0.05) { c.beginPath(); c.ellipse(0, -30 * u, 16 * u, 9 * u, 0, 0, TAU); c.fillStyle = rgba(hex('#BFE3FF'), 0.35 * night); c.fill(); } + const lid = [[-11 * u, -14.5 * u], [-12 * u, -29 * u], [12 * u, -29 * u], [11 * u, -14.5 * u]]; + print(c, lid, mul(C.steel, P.paper)); + c.beginPath(); edges(c, lid, DET, 11, true, { ov: 0.8 }); c.fillStyle = INK; c.fill(); + const st = [[-1.6 * u, -18 * u], [-1.1 * u, -25 * u], [1.1 * u, -25 * u], [1.6 * u, -18 * u]]; + print(c, st, mul(hex('#FFFFFF'), P.paper)); c.fillStyle = rgba(mul(C.chimney, P.paper)); c.fillRect(-1.4 * u + REGX, -22.5 * u + REGY, 2.8 * u, 1.6 * u); + // hooves on the keys, one and then the other + c.beginPath(); + for (const sx of [-1, 1]) { + const up = s.reduced ? 0 : Math.max(0, Math.sin(typing + (sx > 0 ? Math.PI : 0))) * 2.4 * u; + ribbon(c, [[sx * 10 * u, -22 * u], [sx * 13 * u, -17 * u - up], [sx * 11 * u, -15.5 * u - up]], DET * 1.3, 12 + sx, { tin: 1, tout: 1 }); + } + c.fillStyle = INK; c.fill(); + // tak tak + if (!s.reduced) { + const ph = (s.t * 1.3) % 1; + label(c, ph < 0.5 ? 'tak' : 'tak tak', 15 * u, -34 * u - ph * 6 * u, 8 * u + 2, rgba(s.d > 0.55 ? P.paper : P.ink, 1 - ph), 'left', -0.15); + } + // a mug of something + const mug = [[16 * u, -14 * u], [16.5 * u, -20 * u], [21 * u, -20 * u], [20.5 * u, -14 * u]]; + print(c, mug, mul(hex('#FF4A36'), P.paper)); c.beginPath(); edges(c, mug, TEX, 13, true, { ov: 0.6 }); ribbon(c, ellipse(21.5 * u, -17 * u, 2 * u, 2 * u, 8, -1.2, 1.2), TEX, 14, { tin: 1, tout: 1 }); c.fillStyle = INK; c.fill(); + c.restore(); +} + +/* tiling: four numbered beach huts; the focused workspace's hut is lit, its door open */ +function hutSpots(s) { + const g = s.g; + if (g.portrait) return [0.38, 0.54, 0.7, 0.86].map(fx => { const x = fx * g.W; return { x, y: g.top + (g.H - g.top) * 0.455 + (fx - 0.38) * g.H * 0.05 }; }); + return [0.625, 0.705, 0.785, 0.865].map(fx => ({ x: fx * g.W, y: g.top + (g.H - g.top) * 0.82 })); +} +function drawHuts(s, c, p) { + const g = s.g, P = s.P, spots = hutSpots(s), white = mul(hex('#FFFFFF'), P.paper); + const k0 = pop(p) * (g.portrait ? 1.3 : 1.75); + // the boardwalk they share + if (k0 > 0.05) { + const u = g.u * k0, a = spots[0], b = spots[3], walk = [[a.x - 30 * u, a.y], [b.x + 30 * u, b.y], [b.x + 30 * u, b.y + 4 * u], [a.x - 30 * u, a.y + 4 * u]]; + print(c, walk, mul(C.wood, P.paper)); + c.beginPath(); edges(c, walk, TEX * 1.2, 2, true, { ov: 1.2 }); + for (let x = a.x - 22 * u; x < b.x + 30 * u; x += 9 * u) ribbon(c, [[x, a.y + lerp(0, b.y - a.y, (x - a.x) / (b.x - a.x)) + 0.5 * u], [x, a.y + lerp(0, b.y - a.y, (x - a.x) / (b.x - a.x)) + 3.5 * u]], TEX * 0.8, x, { tin: 1, tout: 1, amp: 0.2 }); + c.fillStyle = INK; c.fill(); + } + spots.forEach((q, i) => { + const sc = pop(clamp(p * 1.6 - i * 0.15)) * (g.portrait ? 1.3 : 1.75), u = g.u * sc; + if (sc < 0.05) return; + const on = s.ws === i + 1, col = mul(C.huts[i], P.paper); + const w = 26 * u, h = 27 * u, x0 = q.x - w / 2, y0 = q.y - h; + const body = [[x0, q.y], [x0, y0], [x0 + w, y0], [x0 + w, q.y]]; + print(c, body, white); + c.save(); poly(c, body, true, REGX, REGY); c.clip(); c.fillStyle = rgba(col); + for (let k = 0; k < 4; k++) c.fillRect(x0 + REGX + k * w / 4, y0 + REGY - 1, w / 8, h + 2); + c.restore(); + const roof = [[x0 - 4 * u, y0 + 1 * u], [q.x, y0 - 14 * u], [x0 + w + 4 * u, y0 + 1 * u]]; + print(c, roof, col); + // the door: shut with its number painted on, or open on a warm room + const dw = w * 0.5, dh = h * 0.74, dx = q.x - dw / 2, dy = q.y - dh, door = [[dx, q.y], [dx, dy], [dx + dw, dy], [dx + dw, q.y]]; + const lit = mix(P.beam, hex('#FFB84A'), 0.3); + let num = [q.x, dy + dh * 0.45, 1]; + if (on) { + poly(c, door); c.fillStyle = rgba(lit); c.fill(); + const leaf = [[dx + dw, q.y], [dx + dw, dy], [dx + dw * 1.55, dy + 2.5 * u], [dx + dw * 1.55, q.y - 1.2 * u]]; + print(c, leaf, mix(col, hex('#000000'), 0.12)); + c.beginPath(); edges(c, leaf, TEX * 1.2, i + 3, true, { ov: 0.4 }); c.fillStyle = INK; c.fill(); + num = [dx + dw * 1.29, dy + dh * 0.45, 0.85]; + } else { + print(c, door, mix(col, hex('#000000'), 0.12)); + } + // the round window in the gable + dot(c, q.x + REGX, y0 - 5 * u + REGY, 3.4 * u, on ? lit : mix(P.sky, P.ink, 0.25 + 0.35 * smooth(0.4, 0.8, s.d))); + c.beginPath(); + edges(c, roof, DET, i * 7, true, { ov: 1 }); edges(c, body, DET, i * 7 + 2, false, { ov: 0.8 }); + edges(c, door, TEX * 1.2, i * 7 + 4, false, { ov: 0.4 }); + ribbon(c, ellipse(q.x, y0 - 5 * u, 3.4 * u, 3.4 * u, 12), TEX, i, { tin: 1, tout: 1, w0: 1, w1: 1, outline: true }); + c.fillStyle = INK; c.fill(); + label(c, String(i + 1), num[0] + 0.5 * u, num[1], 11 * u * num[2] + 2, rgba(white), 'center', 0); + }); +} + +/* themes: the washing line, with the other pyjamas drying */ +function drawWashing(s, c, p) { + const g = s.g, u = g.u, P = s.P, T = g.lh, sc = pop(p); + if (sc < 0.05) return; + const px = g.portrait ? g.W * 0.4 : g.W * 0.615, pry = s.ridgeY(px) ?? T.base; + const ph = (g.portrait ? 48 : 92) * u * sc, top = [px, pry - ph], end = [T.x - lerp(T.wB, T.wT, 0.3) / 2, T.base - T.h * 0.3]; + c.beginPath(); + ribbon(c, dens([[px, pry + 3 * u], top], 5), DET * 1.1, 1, { tin: 2, tout: 2 }); + ribbon(c, [[px - 6 * u, top[1] + 4 * u], [px, top[1] - 1 * u], [px + 6 * u, top[1] + 4 * u]], TEX, 2, { tin: 1, tout: 1 }); + const sag = 16 * u, line = []; + for (let i = 0; i <= 20; i++) { const f = i / 20; line.push([lerp(top[0], end[0], f), lerp(top[1], end[1], f) + Math.sin(f * Math.PI) * sag]); } + ribbon(c, line, TEX, 3, { tin: 2, tout: 2 }); + c.fillStyle = INK; c.fill(); + const others = Object.keys(THEMES).filter(k => k !== s.theme); + const wind = s.reduced ? 0.1 : s.windTotal; + others.forEach((id, i) => { + const f = (i + 0.7) / (others.length + 0.4), ax = lerp(top[0], end[0], f), ay = lerp(top[1], end[1], f) + Math.sin(f * Math.PI) * sag; + const k = sc * (g.portrait ? 1 : 1.45), w = 13 * u * k, h = 14 * u * k, sw = (s.reduced ? 0 : Math.sin(s.t * 2.2 + i) * 0.08) + wind * 0.12; + c.save(); c.translate(ax, ay); c.rotate(sw); + const shirt = [[-w / 2, 0], [-w * 0.85, h * 0.25], [-w * 0.62, h * 0.4], [-w * 0.42, h * 0.3], [-w * 0.42, h], [w * 0.42, h], [w * 0.42, h * 0.3], [w * 0.62, h * 0.4], [w * 0.85, h * 0.25], [w / 2, 0]]; + print(c, shirt, mul(hex('#FFFFFF'), P.paper)); + c.save(); poly(c, shirt, true, REGX, REGY); c.clip(); + const st = THEMES[id].stripes; + for (let j = 0; j < 3; j++) { c.fillStyle = rgba(mul(hex(st[j % st.length]), P.paper)); c.fillRect(-w + REGX, h * (0.18 + j * 0.3) + REGY, w * 2, h * 0.15); } + c.restore(); + c.beginPath(); edges(c, shirt, TEX, i * 3, true, { ov: 0.3 }); + ribbon(c, [[-w * 0.2, -2 * u], [-w * 0.2, 3 * u]], DET, i, { tin: 1, tout: 1, w0: 1, w1: 1 }); ribbon(c, [[w * 0.2, -2 * u], [w * 0.2, 3 * u]], DET, i + 1, { tin: 1, tout: 1, w0: 1, w1: 1 }); + c.fillStyle = INK; c.fill(); + c.restore(); + }); +} + +/* ---------- the hero's labels, each with a hand-drawn arrow ---------- */ +function arrow(c, pts, w, sd) { + const p = curve(pts, 4), n = p.length, [x1, y1] = p[n - 1], [x0, y0] = p[Math.max(0, n - 4)], a = Math.atan2(y1 - y0, x1 - x0), h = 8 * PW; + c.beginPath(); + ribbon(c, p, w, sd, { tin: 4, tout: 2, w0: 0.5, w1: 0.7 }); + ribbon(c, [[x1 + Math.cos(a + 2.6) * h, y1 + Math.sin(a + 2.6) * h], [x1, y1], [x1 + Math.cos(a - 2.5) * h, y1 + Math.sin(a - 2.5) * h]], w, sd + 1, { tin: 2, tout: 2, w0: 0.6, w1: 0.6 }); + c.fill(); +} +function drawLabels(s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, night = smooth(0.5, 0.7, s.d); + const col = rgba(mix(P.ink, P.paper, night), s.pres.hero); + const size = Math.max(13, 17 * u); + c.fillStyle = col; + if (g.portrait) { + // the lighthouse, from the left of its lamp + const tx = T.x - T.wB * 1.9, ty = T.top + T.h * 0.3; + label(c, 'a lighthouse (stable)', tx, ty, size, col, 'right'); + c.fillStyle = col; arrow(c, [[tx + 4, ty - 1], [tx + 14, ty - 5], [T.x - T.wB * 0.62, T.top + T.h * 0.2]], TEX * 1.1, 8); + } else { + const tipX = T.x - T.wB * 0.62, tipY = T.top + T.h * 0.6, ax = tipX - 88 * u, ay = tipY + 38 * u; + label(c, 'a lighthouse (stable)', ax, ay, size, col, 'right'); + c.fillStyle = col; arrow(c, [[ax + 6 * u, ay - 3 * u], [ax + 42 * u, ay - 22 * u], [tipX - 6 * u, tipY]], TEX * 1.1, 8); + } + // the cloud with legs + const sc = g.sheepCloud, p = s.cloudXY(sc), w = sc.w; + const lx = p.x + w * 0.42 + (g.portrait ? 6 : 14 * u), ly = p.y + (g.portrait ? 12 : 22 * u); + if (lx + 16 * u + textW(s, 'a cloud (or a sheep)', size) < (g.portrait ? g.lamp.x - T.er * 1.7 : T.x - T.er * 3)) { + label(c, 'a cloud (or a sheep)', lx + 16 * u, ly + 4 * u, size, col, 'left'); + c.fillStyle = col; arrow(c, [[lx + 12 * u, ly + 3 * u], [lx, ly + 2 * u], [p.x + w * 0.36, p.y + 6 * u]], TEX * 1.1, 9); + } + // the sheep in its box, where placeBox found room for the label + const bx = s.box; + if (bx.lastSide && bx.la > 0.01 && bx.gx != null) { + const p = s.boxAt(true), q = boxLabel(p.x, p.y, p.k, u, size, bx.tw, bx.lastSide), lc = rgba(mix(P.ink, P.paper, night), s.pres.hero * bx.la); + label(c, s.boxEmpty() ? 'a box (empty)' : 'a sheep (in a box)', q.tx, q.ty, size, lc, q.align); + c.fillStyle = lc; arrow(c, q.arrow, TEX * 1.1, 11); + c.fillStyle = col; + } + // the sea + const sea = seaLabelSpot(s); + if (sea) { + const wTxt = textW(s, 'the sea (wet)', size); + label(c, 'the sea (wet)', sea.x, sea.y, size, rgba(P.paper, s.pres.hero), 'left'); + c.fillStyle = rgba(P.paper, s.pres.hero); arrow(c, [[sea.x + wTxt * 0.62, sea.y + 11 * u], [sea.x + wTxt * 0.5, sea.y + 22 * u], [sea.x + wTxt * 0.3, sea.y + 30 * u]], TEX * 1.1, 10); + } +} +function textW(s, text, size) { const c = s.ctx; c.save(); c.font = `${WEIGHT} ${size}px ${HAND}`; const w = c.measureText(CAPS ? text.toUpperCase() : text).width; c.restore(); return w; } +// what "a lighthouse (stable)" and its arrow cover (as drawLabels places them), for keeping out of their way +function lighthouseLabelRects(s, w, size) { + const g = s.g, u = g.u, T = g.lh, h = size * 0.75; + if (g.portrait) { + const tx = T.x - T.wB * 1.9, ty = T.top + T.h * 0.3; + return [[tx - w - 4, ty - h, tx + 4, ty + h], [tx, T.top + T.h * 0.2 - 6, T.x - T.wB * 0.62 + 4, ty + 4]]; + } + const tipX = T.x - T.wB * 0.62, tipY = T.top + T.h * 0.6, ax = tipX - 88 * u, ay = tipY + 38 * u; + return [[ax - w - 4, ay - h, ax + 4, ay + h], [ax, tipY - 6, tipX + 4, ay]]; +} +/* "a sheep (in a box)" for a box at (x, y): below it, or beside it, with an arrow back to the box; + the text's anchor and alignment, the arrow, and the rect the two take */ +function boxLabel(x, y, k, u, size, tw, side) { + const h = size * 0.75, R = (BOX.hw + BOX.dx) * k, Lx = BOX.hw * k, B = BOX.hh * k; + if (side === 'below') { + const tx = x - Lx * 0.35, ty = y + B + 12 * u + size * 0.55; + return { tx, ty, align: 'left', arrow: [[tx + 3 * u, ty - h * 1.05], [tx - 3 * u, ty - h * 1.6], [x - Lx * 0.5, y + B + 3 * u]], x0: Math.min(tx, x - Lx) - 4, y0: y + B + 2, x1: tx + tw + 4, y1: ty + h }; + } + if (side === 'right') { + const tx = x + R + 22 * u, ty = y + 9 * u; + return { tx, ty, align: 'left', arrow: [[tx - 3 * u, ty - 1 * u], [tx - 11 * u, ty - 2 * u], [x + R + 4 * u, y + 1 * u]], x0: x + R + 2, y0: ty - h, x1: tx + tw + 4, y1: ty + h }; + } + const tx = x - Lx - 20 * u, ty = y + 9 * u; + return { tx, ty, align: 'right', arrow: [[tx + 3 * u, ty - 1 * u], [tx + 11 * u, ty - 2 * u], [x - Lx - 4 * u, y + 1 * u]], x0: tx - tw - 4, y0: ty - h, x1: x - Lx - 2, y1: ty + h }; +} +// where the widgets section's sheep sits at its desk +function deskSpot(s) { + const g = s.g, x = g.portrait ? g.W * 0.5 : g.W * 0.7, ry = s.ridgeY(x); + return { x, y: g.portrait ? ry + (s.shoreY(x) - ry) * 0.66 : ry + g.H * 0.2 }; +} +function seaLabelSpot(s) { + const g = s.g, u = g.u, wTxt = 112 * u + 10; + if (g.portrait) { const y = g.horizon + (g.H - g.horizon) * 0.55; return { x: g.W * 0.3, y }; } + // in the sea, right of the notes or below them, clear of the key + for (const fy of [0.07, 0.14, 0.3, 0.55, 0.7, 0.85]) { + const y = g.horizon + (g.H - g.horizon) * fy, right = s.hillLeftX(y + 26 * u) - 10 * u; + let x0 = 30 * u + 22 * u; + if (y < g.note.y1) x0 = Math.max(x0, g.note.x1 + 16 * u); + if (g.key && y + 30 * u > g.key.y0) x0 = Math.max(x0, g.key.x1 + 24 * u); + if (right - x0 > wTxt) return { x: x0 + Math.min(20 * u, (right - x0 - wTxt) * 0.4), y: y + 8 * u }; + } + return null; +} + +/* ---------- surprises ---------- */ +function drawObj(s, c, o) { + const g = s.g, P = s.P, k = g.u * o.sc, lit = o.lit * s.beamI(), sq = s.squash(o), t = s.reduced ? 0 : s.t; + if (o.dying != null) c.globalAlpha = clamp(1 - (s.t - o.dying) / 0.7); + // a sheep pleased with its meal hops, twice + const ja = t - (o.joy ?? -99), hop = o.kind === 'sheep' && ja >= 0 && ja < 0.8 ? Math.abs(Math.sin(ja / 0.4 * Math.PI)) * 6 * k * (1 - ja / 0.8) : 0; + c.save(); c.translate(o.x, o.y - hop); + if (lit > 0.15) { + // a startled "!", in the pen + const jy = -40 * k - Math.abs(Math.sin(t * 8)) * 3 * k; + c.globalAlpha *= clamp(lit * 1.6); + c.beginPath(); ribbon(c, [[2.6 * k, jy - 15 * k], [2 * k, jy - 6 * k], [1.6 * k, jy - 1 * k]], DET * 1.35, o.seed, { tin: 1, tout: 4, w0: 1, w1: 0.3 }); c.fillStyle = INK; c.fill(); + dot(c, 1.3 * k, jy + 4 * k, 1.7 * PW, INK); + c.globalAlpha = o.dying != null ? clamp(1 - (s.t - o.dying) / 0.7) : 1; + } + c.scale((o.flip ? -1 : 1) * (1 + sq * 0.6), 1 - sq); + const tint = base => mix(mul(base, P.paper), P.beam, clamp(lit) * 0.75); + switch (o.kind) { + case 'sheep': DRAW.sheep(c, k, tint(C.wool), o, t); break; + case 'cow': DRAW.cow(c, k, tint(hex('#FFFFFF')), P, o, t); break; + case 'gull': DRAW.gull(c, k, tint(hex('#FFFFFF')), o, t); break; + case 'duck': DRAW.duck(c, k, tint(C.duck), P, o, t); break; + case 'monster': DRAW.monster(c, k, tint(C.monster), P, o, t); break; + } + c.restore(); + c.globalAlpha = 1; +} +/* a grazer's head: up, or lowered into the grass and chewing; `px, py` is where the neck turns, `to` where it + goes when the head is down. Draws the head with `draw`, in the head-up coordinates. */ +function head(c, u, o, t, px, py, to, turn, draw) { + const hd = o.head || 0, chew = o.act === 'eat' ? Math.sin(t * 9 + o.seed) * hd : 0; + c.save(); + c.translate(lerp(px, to[0], hd) * u, (lerp(py, to[1], hd) + chew * 0.55) * u); + c.rotate(hd * turn + chew * 0.07); + c.translate(-px * u, -py * u); + draw(chew); + c.restore(); +} +// asleep: the eye shut, a short curve +function napEye(c, x, y, r, col) { + c.beginPath(); ribbon(c, ellipse(x, y - r * 0.5, r * 1.2, r * 0.9, 8, Math.PI * 0.1, Math.PI * 0.9), TEX, 4, { tin: 1, tout: 1, w0: 0.9, w1: 0.9, amp: 0.1 }); + c.fillStyle = col; c.fill(); +} +// pleased with a meal: a short while after it, eyes shut happily and the tail wags +const joyAt = (o, t) => { const a = t - (o.joy ?? -99); return a >= 0 && a < 1.3 ? a : -1; }; +function drawSheep(c, u, wool, o, t) { + const fall = o.state === 'fall' || o.state === 'leap', step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 14 + o.seed) * 2 * u : 0; + c.beginPath(); + [-8, -3, 5, 10].forEach((lx, i) => ribbon(c, [[lx * u, -10 * u], [lx * u + (fall ? 3 * u : (i % 2 ? step : -step)), 0]], DET * 1.05, i, { tin: 1, tout: 1, w0: 0.9, w1: 1 })); + c.fillStyle = INK; c.fill(); + const body = []; + for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = 1 + 0.13 * Math.abs(Math.sin(a * 5 + o.seed)); body.push([Math.cos(a) * 14 * u * r, -16 * u + Math.sin(a) * 9.5 * u * r]); } + shape(c, body, wool, DET, o.seed % 7); + // a stubby tail, wagging when pleased + const ja = joyAt(o, t); + c.save(); c.translate(-14 * u, -19 * u); c.rotate(-0.45 + (ja >= 0 ? Math.sin(ja * 24) * 0.6 * (1 - ja / 1.3) : 0)); + shape(c, ellipse(-2.4 * u, 0, 3 * u, 2.2 * u, 12), wool, TEX * 1.15, 5, { over: 0.05 }); + c.restore(); + // black face, an ear, a tuft of wool on top, an eye + head(c, u, o, t, 15, -19, [19, -7.5], 1.05, chew => { + poly(c, ellipse(15 * u, -19 * u, 5 * u, 6.4 * u, 18, 0, TAU, -0.35)); c.fillStyle = INK; c.fill(); + poly(c, ellipse(11.5 * u, -23.5 * u, 3.6 * u, 1.6 * u, 10, 0, TAU, 0.5 + chew * 0.25)); c.fill(); + const tuft = []; for (let i = 0; i < 10; i++) { const a = Math.PI * 1.05 + i / 9 * Math.PI * 0.9; tuft.push([13.5 * u + Math.cos(a) * 5 * u, -24 * u + Math.sin(a) * 2.6 * u]); } + c.beginPath(); ribbon(c, tuft, TEX, 3, { tin: 1, tout: 1 }); c.fillStyle = rgba(wool); c.fill(); + if (ja >= 0) { c.beginPath(); ribbon(c, ellipse(16.6 * u, -19.6 * u, 1.7 * u, 1.3 * u, 8, Math.PI * 1.1, Math.PI * 1.9), TEX, 4, { tin: 1, tout: 1, w0: 0.9, w1: 0.9, amp: 0.1 }); c.fillStyle = '#FFFFFF'; c.fill(); } + else if (NAP) napEye(c, 16.6 * u, -20.5 * u, 1.5 * u, '#FFFFFF'); + else { dot(c, 16.6 * u, -20.5 * u, 1.5 * u, '#FFFFFF'); dot(c, 17.1 * u, -20.3 * u, 0.75 * u, INK); } + }); +} +function drawCow(c, u, white, P, o, t) { + const step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 9 + o.seed) * 2 * u : 0; + c.beginPath(); + [-12, -7, 8, 13].forEach((lx, i) => ribbon(c, [[lx * u, -11 * u], [lx * u + (i % 2 ? step : -step), 0]], DET * 1.15, i, { tin: 1, tout: 1, w0: 0.9, w1: 1 })); + c.fillStyle = INK; c.fill(); + // the tail swishes now and then, and wags when pleased + const ja = joyAt(o, t), wag = ja >= 0 ? Math.sin(ja * 16) * 0.5 * (1 - ja / 1.3) : Math.sin(t * 1.3 + o.seed) * 0.12; + c.save(); c.translate(-17 * u, -20 * u); c.rotate(wag); c.translate(17 * u, 20 * u); + c.beginPath(); ribbon(c, [[-17 * u, -20 * u], [-21 * u, -14 * u], [-20 * u, -7 * u]], TEX, 5, { tin: 1, tout: 1 }); c.fillStyle = INK; c.fill(); + dot(c, -20 * u, -6 * u, 2 * u, INK); + c.restore(); + const body = curve([[-17 * u, -22 * u], [-6 * u, -26.5 * u], [10 * u, -25.5 * u], [17 * u, -20 * u], [15 * u, -11 * u], [0, -10 * u], [-16 * u, -11 * u], [-18.5 * u, -16 * u]], 3, true); + print(c, body, white); + // patches, inside the body + c.save(); poly(c, body, true, REGX, REGY); c.clip(); + const R = rng(o.seed + 3); + for (let i = 0; i < 3; i++) { const px = (-11 + i * 9 + R() * 4) * u, py = (-21 + R() * 8) * u; poly(c, ellipse(px, py, (3.5 + R() * 3) * u, (2.6 + R() * 2) * u, 12, 0, TAU, R())); c.fillStyle = INK; c.fill(); } + c.restore(); + c.beginPath(); loop(c, body, DET, 3); c.fillStyle = INK; c.fill(); + // head, muzzle (it chews), horns, an ear + head(c, u, o, t, 15, -24, [19, -13], 0.95, chew => { + const face = curve([[15 * u, -29 * u], [21 * u, -31 * u], [24.5 * u, -26 * u], [24.5 * u, -20 * u], [18 * u, -19.5 * u], [15 * u, -24 * u]], 2, true); + shape(c, face, white, DET, 4); + const my = (-21 + Math.max(0, chew) * 0.7) * u; + shape(c, ellipse(23 * u, my, 4 * u, 3 * u, 14), mul(C.pink, P.paper), TEX * 1.2, 2); + dot(c, 22 * u, my, 0.7 * u, INK); dot(c, 24.6 * u, my - 0.2 * u, 0.7 * u, INK); + if (ja >= 0) { c.beginPath(); ribbon(c, ellipse(20.2 * u, -25.8 * u, 1.5 * u, 1.1 * u, 8, Math.PI * 1.1, Math.PI * 1.9), TEX, 6, { tin: 1, tout: 1, amp: 0.1 }); c.fillStyle = INK; c.fill(); } + else if (NAP) napEye(c, 20.2 * u, -26.6 * u, 1.3 * u, INK); + else dot(c, 20.2 * u, -26.6 * u, 1.05 * u, INK); + c.beginPath(); + ribbon(c, [[16.5 * u, -29.5 * u], [15 * u, -33.5 * u], [16.5 * u, -36 * u]], TEX * 1.3, 1, { tin: 1, tout: 2 }); + ribbon(c, [[21 * u, -31 * u], [22.5 * u, -34.5 * u], [21.5 * u, -37 * u]], TEX * 1.3, 2, { tin: 1, tout: 2 }); + c.fillStyle = INK; c.fill(); + poly(c, ellipse(13.6 * u, -27.5 * u, 3 * u, 1.5 * u, 10, 0, TAU, 0.4 + chew * 0.2)); c.fillStyle = INK; c.fill(); + }); +} +function drawGull(c, u, white, o, t) { + const fl = o.state === 'hover' ? 2 * u : Math.sin(t * 9 + o.seed) * 6 * u; + c.beginPath(); + ribbon(c, curve([[-1 * u, -4 * u], [-8 * u, -11 * u - fl * 0.4], [-14 * u, -10 * u - fl], [-20 * u, -6 * u - fl]], 2), DET * 1.15, 3, { tin: 2, tout: 6, w0: 1, w1: 0.2 }); + ribbon(c, curve([[2 * u, -4 * u], [8 * u, -11 * u - fl * 0.4], [14 * u, -10 * u - fl], [20 * u, -6 * u - fl]], 2), DET * 1.15, 4, { tin: 2, tout: 6, w0: 1, w1: 0.2 }); + c.fillStyle = INK; c.fill(); + shape(c, ellipse(0, -2.5 * u, 7.5 * u, 3.6 * u, 18), white, DET * 0.9, 1); + dot(c, 5 * u, -4 * u, 3 * u + REGX * 0.2, white); + poly(c, [[7 * u, -4 * u], [12 * u, -3 * u], [7 * u, -1.8 * u]]); c.fillStyle = rgba(C.beak); c.fill(); + dot(c, 5.6 * u, -4.6 * u, 0.8 * u, INK); +} +function drawDuck(c, u, yellow, P, o, t) { + const body = curve([[-12 * u, -8 * u], [-14 * u, -13 * u], [-8 * u, -10 * u], [2 * u, -10.5 * u], [10 * u, -8 * u], [11 * u, -3 * u], [4 * u, -0.5 * u], [-8 * u, -0.5 * u]], 2, true); + shape(c, body, yellow, DET, 1); + const head = ellipse(6.5 * u, -15 * u, 6 * u, 5.6 * u, 22); + shape(c, head, yellow, DET, 2); + const beak = [[11.5 * u, -15.5 * u], [17.5 * u, -14 * u], [16 * u, -12 * u], [11.5 * u, -12.2 * u]]; + print(c, beak, C.beak); + c.beginPath(); loop(c, dens(beak, 2, true), TEX * 1.2, 5); ribbon(c, ellipse(-1 * u, -6 * u, 6 * u, 3 * u, 10, 0.2, 2.2), TEX, 6, { tin: 1, tout: 2 }); c.fillStyle = INK; c.fill(); + if (NAP) napEye(c, 8 * u, -16.5 * u, 1.4 * u, INK); + else { dot(c, 8 * u, -16.5 * u, 1.35 * u, INK); dot(c, 7.6 * u, -17 * u, 0.45 * u, '#FFFFFF'); } + // a little water at the waterline + c.beginPath(); ribbon(c, [[-13 * u, 0.5 * u], [-4 * u, -1.2 * u], [5 * u, 0.6 * u], [13 * u, -0.6 * u]], TEX, 7, { tin: 2, tout: 2 }); c.fillStyle = rgba(P.foam); c.fill(); +} +function drawMonster(c, u, pink, P, o, t) { + const sw = Math.sin(t * 1.6 + o.seed); + for (let i = 0; i < 3; i++) { + const hx = (-30 + i * 19) * u, hh = (8 + 2.5 * Math.sin(t * 2 + i * 1.3)) * u * (0.85 + 0.15 * sw); + const hump = ellipse(hx, 1 * u, 8.5 * u, hh, 14, Math.PI, TAU); + print(c, [...hump, [hx + 8.5 * u, 1 * u]], pink); + c.beginPath(); ribbon(c, hump, DET, i, { tin: 2, tout: 2, outline: true }); c.fillStyle = INK; c.fill(); + c.beginPath(); ribbon(c, [[hx - 11 * u, 1.5 * u], [hx, 0], [hx + 11 * u, 1.5 * u]], TEX, i + 4, { tin: 2, tout: 2 }); c.fillStyle = rgba(P.foam); c.fill(); + } + const neck = curve([[20 * u, 1 * u], [22 * u, -10 * u], [27 * u, -20 * u], [34 * u, -25 * u], [42 * u, -26 * u], [48 * u, -21 * u], [46 * u, -16 * u], [38 * u, -16 * u], [33 * u, -11 * u], [31 * u, 1 * u]], 2); + print(c, neck, pink); + c.beginPath(); ribbon(c, neck, DET, 7, { tin: 2, tout: 2, outline: true }); c.fillStyle = INK; c.fill(); + c.beginPath(); ribbon(c, [[16 * u, 1.5 * u], [26 * u, 0], [36 * u, 1.5 * u]], TEX, 8, { tin: 2, tout: 2 }); c.fillStyle = rgba(P.foam); c.fill(); + dot(c, 41 * u, -21.5 * u, 2.6 * u, '#FFFFFF'); + c.beginPath(); ribbon(c, ellipse(41 * u, -21.5 * u, 2.6 * u, 2.6 * u, 10), TEX, 9, { tin: 1, tout: 1, w0: 1, w1: 1, outline: true }); c.fillStyle = INK; c.fill(); + dot(c, 41.8 * u, -21.3 * u, 1.1 * u, INK); + c.beginPath(); ribbon(c, [[45 * u, -18 * u], [47 * u, -17.6 * u]], TEX, 10, { tin: 1, tout: 1 }); c.fill(); + // its little top hat, with a red band + const hat = [[36 * u, -25.5 * u], [37 * u, -35 * u], [45 * u, -35.5 * u], [45.5 * u, -26 * u]]; + poly(c, hat); c.fillStyle = INK; c.fill(); + c.fillStyle = rgba(mul(C.chimney, P.paper)); c.fillRect(36.6 * u, -29.5 * u, 8.6 * u, 2 * u); + c.beginPath(); ribbon(c, [[33 * u, -25.8 * u], [49 * u, -26.4 * u]], DET * 1.2, 2, { tin: 1, tout: 1, w0: 1, w1: 1 }); c.fillStyle = INK; c.fill(); +} + +/* ---------- the lighthouse plays with the bar: the ball, its doodles and the splash ---------- */ +function drawBall(s, c) { + const b = s.ball; + if (!b) return; + const g = s.g, u = g.u, pr = g.lh.er * 0.66, v = Math.hypot(b.vx, b.vy); + // a couple of speed lines behind it when it's going + if (v > 240 * u && !s.reduced) { + const ax = -b.vx / v, ay = -b.vy / v, nx = -ay, ny = ax, l = pr * clamp(v / (700 * u), 0.6, 1.4); + c.beginPath(); + for (const k of [-0.5, 0.42]) { + const x0 = b.x + ax * pr * 1.4 + nx * pr * k, y0 = b.y + ay * pr * 1.4 + ny * pr * k; + ribbon(c, [[x0, y0], [x0 + ax * l * (1 - k * 0.3), y0 + ay * l * (1 - k * 0.3)]], TEX, 30 + k * 5, { tin: 1, tout: 4 }); + } + c.fillStyle = INK; c.fill(); + } + // squashed for a moment by a hit or a header + const q = s.t - b.sq, sq = q < 0.16 ? Math.sin(q / 0.16 * Math.PI) * 0.24 : 0; + c.save(); c.translate(b.x, b.y); c.scale(1 + sq, 1 - sq); + bobble(c, 0, 0, pr, mul(hex('#FFFFFF'), s.P.paper), b.spin); + c.restore(); +} + +/* a quaver, drawn: a tilted head, a stem and a flag */ +function quaver(c, x, y, sz, col, flip) { + c.beginPath(); c.ellipse(x, y, sz * 0.62, sz * 0.44, -0.45, 0, TAU); c.fillStyle = col; c.fill(); + c.beginPath(); + ribbon(c, [[x + sz * 0.52, y - sz * 0.1], [x + sz * 0.56, y - sz * 2.1]], TEX * 1.2, 3, { tin: 1, tout: 1, w0: 0.9, w1: 0.9 }); + ribbon(c, [[x + sz * 0.56, y - sz * 2.1], [x + sz * (0.56 + 0.6 * flip), y - sz * 1.62], [x + sz * (0.56 + 0.45 * flip), y - sz * 1.0]], TEX * 1.1, 4, { tin: 1, tout: 3 }); + c.fillStyle = col; c.fill(); +} + +/* "boink!", "pok", "plop", "hehe", the whistled notes, the grazers' "nom"s and the box's "baa!": they pop in, + drift a little and fade (without motion they just show, and go) */ +function drawDoodles(s, c) { + if (!s.doodles.length) return; + const g = s.g, u = g.u, P = s.P, rm = s.reduced, ink = rgba(mix(P.ink, P.paper, smooth(0.5, 0.7, s.d))); + for (const d of s.doodles) { + const a = s.t - d.t0, k = rm ? 1 : pop(a / 0.24), f = 1 - smooth(d.life - 0.3, d.life, a), col = d.sea ? rgba(P.paper) : ink; + if (f <= 0 || k <= 0.01) continue; + c.save(); c.globalAlpha = f; + if (d.kind === 'note') { + const sz = 4.2 * u + 1.6; + quaver(c, d.x + Math.sin(a * 4 + d.seed) * 5 * u, d.y - a * 36 * u, sz * k, col, d.flip); + c.restore(); continue; + } + const munch = d.kind === 'munch', dir = d.align === 'right' ? -1 : 1; + const size = munch ? Math.max(11, 12 * u + 2.5) : Math.max(d.kind === 'pok' ? 12 : 13.5, (d.kind === 'pok' ? 12 : d.kind === 'boink' ? 16 : 15) * u + 4); + const shake = d.kind === 'hehe' && !rm ? (Math.floor(a * 9) % 2 ? -1 : 1) * 1.3 * u : 0; + c.translate(d.x, d.y - (rm ? 0 : a * (d.kind === 'plop' ? 12 : munch ? 7 : 8) * u) + shake); c.scale(k, k); + const text = d.text || (d.kind === 'boink' ? 'boink!' : d.kind); + label(c, text, 0, 0, size, col, d.align, d.kind === 'boink' ? -0.09 * dir : d.kind === 'pok' ? 0.12 * dir : munch ? -0.1 * dir : d.kind === 'baa' || d.kind === 'knock' ? 0.1 * dir : -0.06); + c.beginPath(); + if (d.kind === 'boink') { + // the knock, in three short strokes toward the widget + for (const i of [0, 1, 2]) { const an = -Math.PI / 2 - dir * (0.25 + i * 0.42), r0 = size * 0.32, r1 = size * 0.72; ribbon(c, [[Math.cos(an) * r0, -size * 0.3 + Math.sin(an) * r0], [Math.cos(an) * r1, -size * 0.3 + Math.sin(an) * r1]], TEX * 1.1, i + 7, { tin: 1, tout: 2 }); } + } else if (d.kind === 'baa' || d.kind === 'knock') { + // two short strokes, from the box (or the door) toward the word + for (const i of [0, 1]) { const an = Math.PI * (i ? 0.1 : -0.12), x0 = -dir * size * 0.6, r = size * 0.42; ribbon(c, [[x0 - dir * Math.cos(an) * r * 0.2, -size * 0.1 + Math.sin(an) * r * 0.6 + i * size * 0.38 - size * 0.12], [x0 + dir * Math.cos(an) * r * 0.55, -size * 0.1 + i * size * 0.38 - size * 0.12]], TEX * 1.1, i + 5, { tin: 1, tout: 2 }); } + } else if (d.kind === 'plop') { + // two drops over the word + for (const i of [0, 1]) { const x = size * (0.45 + i * 0.85), y = -size * (0.9 + i * 0.2); ribbon(c, ellipse(x, y, size * 0.13, size * 0.18, 10, -0.6, Math.PI * 1.6), TEX, i + 3, { tin: 1, tout: 1 }); } + } + c.fillStyle = col; c.fill(); + c.restore(); + } +} + +/* where the ball lands: a crown of water and a few drops thrown up and out, in foam with the pen round them */ +function drawSplashes(s, c) { + if (!s.splashes.length) return; + const g = s.g, u = g.u, P = s.P, foam = rgba(P.foam); + for (const p of s.splashes) { + const a = s.t - p.t0, k = clamp(a / 0.7), e = easeOut(k); + c.globalAlpha = 1 - smooth(0.6, 1, k); + for (const [w, col] of [[DET * 2.5, INK], [DET * 1.25, foam]]) { + c.beginPath(); + for (let i = 0; i < 5; i++) { + const an = -Math.PI / 2 + (i - 2) * 0.46, r0 = 6 * u, r1 = r0 + (8 + 20 * e) * u * (1 - k * 0.55); + ribbon(c, [[p.x + Math.cos(an) * r0 * 1.5, p.y + Math.sin(an) * r0 * 0.5], [p.x + Math.cos(an) * r1 * 1.5, p.y + Math.sin(an) * r1]], w, i, { tin: 2, tout: 3, amp: 0.4 }); + } + c.fillStyle = col; c.fill(); + } + for (let i = 0; i < 4; i++) { + const dx = (i - 1.5) * (10 + 22 * e) * u, dy = -(30 + i % 2 * 10) * u * e + 70 * u * k * k, x = p.x + dx, y = p.y + dy, r = (2.8 - 1.4 * k) * u + 0.8; + if (y > p.y + 2) continue; + dot(c, x, y, r, foam); + c.beginPath(); ribbon(c, ellipse(x, y, r + 0.6, r + 0.6, 10), TEX * 0.8, i, { tin: 1, tout: 1, w0: 1, w1: 1, outline: true }); c.fillStyle = INK; c.fill(); + } + c.globalAlpha = 1; + } +} + +window.Hill = { + Scene, themes: Object.keys(THEMES), states: STATES, islands: ISLANDS.length, + // for the styles (see STYLES): style registers one, felt is the felt pen's with every lever + style, felt: FELT, PAL, THEMES, BOX, STEPS, SIL, DET, TEX, DIVE_GAP, SHORE, FAR, + + TAU, clamp, lerp, smooth, easeOut, hash, noise, rng, hex, mix, mul, rgba, angDiff, stops, palAt, + curve, dens, ellipse, poly, inPoly, cloudPts, ink, loop, edges, shape, dot, label, arrow, quaver, bobble, pop, + hatGeom, head, napEye, joyAt, beamPoly, waveScallops, rank, lightK, view, afloat, hutSpots, deskSpot, seaLabelSpot, textW, + // what the style being drawn has on + get C() { return C; }, get INK() { return INK; }, get PW() { return PW; }, get BOIL() { return BOIL; }, get NAP() { return NAP; }, + get REGX() { return REGX; }, get REGY() { return REGY; }, get HAND() { return HAND; }, get ribbon() { return ribbon; }, get print() { return print; }, +}; +})(); diff --git a/site/style.css b/site/style.css index df47a672..5701a087 100644 --- a/site/style.css +++ b/site/style.css @@ -1,7 +1,16 @@ +/* stablebar.app: Lighthouse Hill in absurd ink. Paper notes taped over a felt-pen drawing + (scene.js), with the real bar running along the top. */ + +@font-face { + font-family: "Shantell Sans"; + src: url(fonts/shantell-sans-display.woff2) format("woff2"); + font-weight: 800; + font-display: swap; +} @font-face { - font-family: "Host Grotesk"; - src: url(fonts/host-grotesk.woff2) format("woff2"); - font-weight: 300 800; + font-family: "Shantell Sans Hand"; + src: url(fonts/shantell-sans-hand.woff2) format("woff2"); + font-weight: 600; font-display: swap; } @font-face { @@ -10,151 +19,177 @@ font-weight: 200 800; font-display: swap; } +/* Fallbacks scaled to take the same room as the web fonts, so the swap doesn't move the page */ +@font-face { font-family: "Shantell Fallback"; src: local("Arial Bold"), local("Arial-BoldMT"); font-weight: 800; size-adjust: 117%; ascent-override: 87.2%; descent-override: 27.3%; line-gap-override: 0%; } +@font-face { font-family: "Hand Fallback"; src: local("Arial Bold"), local("Arial-BoldMT"); font-weight: 600; size-adjust: 112%; ascent-override: 91%; descent-override: 28.6%; line-gap-override: 0%; } +@font-face { font-family: "Atkinson Fallback"; src: local("Arial"), local("ArialMT"); font-weight: 100 549; size-adjust: 98.6%; ascent-override: 99.8%; descent-override: 32%; line-gap-override: 0%; } +@font-face { font-family: "Atkinson Fallback"; src: local("Arial Bold"), local("Arial-BoldMT"); font-weight: 550 900; size-adjust: 98.6%; ascent-override: 99.8%; descent-override: 32%; line-gap-override: 0%; } :root { - color-scheme: dark; - --ground: #15110d; - --text: #efe6da; - --text-2: #b8ac9d; - --text-3: #a09484; - --line: #efe6da2e; - --line-2: #efe6da66; - --backdrop: url(images/setup-amber-light.webp); - --backdrop-2: url(images/setup-amber-dark.webp); - --dim: linear-gradient(color-mix(in srgb, var(--ground) 40%, transparent) 0 0); + color-scheme: light; + /* the page: white paper notes at every hour, a felt pen, yellow tape */ + --paper: #ffffff; + --text: #141414; + --text-2: #3b3b3b; + --pen: #111111; + --tape: #ffe04ab8; + --hi: #ffe04a; + --red: #d03a28; + --link: #2e58d8; + --ring: #2e58d8; + --sky: #86cbf2; + --sea: #2e58d8; + --screen: #86cbf2; + --strip: var(--sky); + /* the ring around the widget a script makes */ + --amber: #f5821f; + /* the app's windows: Add here, the right-click menu, the Store */ + --win-bg: #f4f2ef; + --win-fg: #1d1d1f; + --win-ring: #00000026; + --win-edge: #ffffff99; + --sys-blue: #0068da; + --blue-fill: #0068da; + --shadow-1: #1b2a5240; + --shadow-2: #1b2a5233; + --ease: cubic-bezier(.22, 1, .36, 1); + --bar-h: 40px; + --display: "Shantell Sans", "Shantell Fallback", "Chalkboard SE", sans-serif; + --hand: "Shantell Sans Hand", "Hand Fallback", "Chalkboard SE", sans-serif; + --body: "Atkinson Hyperlegible Next", "Atkinson Fallback", "Helvetica Neue", sans-serif; + --mac: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, monospace; + /* felt-pen pieces: the washing line, a rule, and the highlighter */ + --string: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 400 104%27 preserveAspectRatio=%27none%27%3E%3Cpath d=%27M0 9 L10 9.9 L20 9.3 L30 9.6 L40 10.8 L50 10.5 L60 10.2 L70 11.4 L80 11.5 L90 10.9 L100 11.7 L110 12.3 L120 11.5 L130 11.8 L140 12.8 L150 12.1 L160 11.9 L170 12.9 L180 12.7 L190 11.9 L200 12.7 L210 13 L220 12 L230 12.3 L240 13 L250 12.1 L260 11.7 L270 12.6 L280 12.1 L290 11.2 L300 11.9 L310 11.9 L320 10.7 L330 10.9 L340 11.4 L350 10.2 L360 9.9 L370 10.5 L380 9.7 L390 8.8 L400 9.3%27 fill=%27none%27 stroke=%27%23111%27 stroke-width=%272%27 stroke-linecap=%27round%27 vector-effect=%27non-scaling-stroke%27/%3E%3C/svg%3E"); + --rule: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%2710%27%3E%3Cpath d=%27M0 5 L2.5 5.7 L5 5.9 L7.5 5.9 L10 6 L12.5 6 L15 5.9 L17.5 5.9 L20 5.8 L22.5 5.7 L25 5.6 L27.5 5.6 L30 5.6 L32.5 5.6 L35 5.7 L37.5 5.8 L40 6 L42.5 6.1 L45 6.3 L47.5 6.4 L50 6.5 L52.5 6.6 L55 6.6 L57.5 6.6 L60 6.5 L62.5 6.3 L65 6.1 L67.5 5.9 L70 5.6 L72.5 5.3 L75 5 L77.5 4.7 L80 4.5 L82.5 4.3 L85 4.1 L87.5 4.1 L90 4 L92.5 4 L95 4.1 L97.5 4.1 L100 4.2 L102.5 4.3 L105 4.4 L107.5 4.4 L110 4.4 L112.5 4.4 L115 4.3 L117.5 4.2 L120 4 L122.5 3.9 L125 3.7 L127.5 3.6 L130 3.5 L132.5 3.4 L135 3.4 L137.5 3.4 L140 3.5 L142.5 3.7 L145 3.9 L147.5 4.1 L150 4.4 L152.5 4.7 L155 5 L157.5 5.3 L160 5.5%27 fill=%27none%27 stroke=%27%23111%27 stroke-width=%272.4%27 stroke-linecap=%27round%27/%3E%3C/svg%3E"); + --rule-night: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%2710%27%3E%3Cpath d=%27M0 5 L2.5 5.7 L5 5.9 L7.5 5.9 L10 6 L12.5 6 L15 5.9 L17.5 5.9 L20 5.8 L22.5 5.7 L25 5.6 L27.5 5.6 L30 5.6 L32.5 5.6 L35 5.7 L37.5 5.8 L40 6 L42.5 6.1 L45 6.3 L47.5 6.4 L50 6.5 L52.5 6.6 L55 6.6 L57.5 6.6 L60 6.5 L62.5 6.3 L65 6.1 L67.5 5.9 L70 5.6 L72.5 5.3 L75 5 L77.5 4.7 L80 4.5 L82.5 4.3 L85 4.1 L87.5 4.1 L90 4 L92.5 4 L95 4.1 L97.5 4.1 L100 4.2 L102.5 4.3 L105 4.4 L107.5 4.4 L110 4.4 L112.5 4.4 L115 4.3 L117.5 4.2 L120 4 L122.5 3.9 L125 3.7 L127.5 3.6 L130 3.5 L132.5 3.4 L135 3.4 L137.5 3.4 L140 3.5 L142.5 3.7 L145 3.9 L147.5 4.1 L150 4.4 L152.5 4.7 L155 5 L157.5 5.3 L160 5.5%27 fill=%27none%27 stroke=%27%23f2eeff%27 stroke-width=%272.4%27 stroke-linecap=%27round%27/%3E%3C/svg%3E"); + --highlight: url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 20%27 preserveAspectRatio=%27none%27%3E%3Cpath d=%27M2 4.5 9 2.6 50 2 92 1.6 98 3.4 96.4 7 99 10.6 97.6 15.2 99.2 18.4 60 18.6 8 19 1 17.4 3 13.2 .8 9.4Z%27 fill=%27%23ffe04a%27/%3E%3C/svg%3E"); +} +:root[data-mode="dark"] { + --sky: #232662; + --sea: #14184a; + --screen: #2f3374; --win-bg: #1e1d1c; --win-fg: #f5f5f7; --win-ring: #000; --win-edge: #ffffff17; - --card-bg: #2a2928; - --card-edge: #ffffff12; - --card-hover: #ffffff40; --sys-blue: #0a84ff; --shadow-1: #000; --shadow-2: #000c; - --code-bg: #100e0c; - --code-fg: #d9cfc2; - --code-dim: #a09484; - --amber: #f5821f; - --amber-ink: #1b1007; - --ring: #f5821f; - --ease: cubic-bezier(.22, 1, .36, 1); - --gutter: 20px; - --bar-h: 40px; - --desk-r: 0px; - --display: "Host Grotesk", "Helvetica Neue", sans-serif; - --body: "Atkinson Hyperlegible Next", "Helvetica Neue", sans-serif; - --mac: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; - --mono: ui-monospace, "SF Mono", Menlo, monospace; -} -:root[data-mode="light"] { - color-scheme: light; - --ground: #f5f0e8; - --text: #1d1813; - --text-2: #4f463d; - --text-3: #60564b; - --line: #1d181329; - --line-2: #1d18137a; - --win-bg: #f4f2ef; - --win-fg: #1d1d1f; - --win-ring: #00000026; - --win-edge: #ffffff99; - --card-bg: #ffffff; - --card-edge: #0000001a; - --card-hover: #00000033; - --sys-blue: #0068da; - --shadow-1: #4a2d1240; - --shadow-2: #4a2d1229; - --code-bg: #fffaf2; - --code-fg: #3b3229; - --code-dim: #6f6559; - --ring: #a3470a; } * { box-sizing: border-box; margin: 0; padding: 0; } html { - background: var(--ground); color: var(--text); + background: var(--sky); color: var(--text); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + scroll-padding-top: calc(var(--bar-h) + 24px); } -body { font: 400 16px/1.55 var(--body); overflow-x: hidden; } -a { color: inherit; text-decoration: none; } +body { font: 400 18px/1.55 var(--body); overflow-x: hidden; touch-action: pan-y pinch-zoom; } +a { color: inherit; } button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } -:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; } -:where(.brand, .nav a, .more-link, footer a, .skip):focus-visible { border-radius: 4px; } -::selection { background: color-mix(in srgb, var(--amber) 40%, transparent); } +:focus-visible { outline: 2.5px solid var(--ring); outline-offset: 3px; border-radius: 4px; } +::selection { background: var(--hi); color: #111; } +p { text-wrap: pretty; } +h1, h2, h3 { text-wrap: balance; } +.num { font-variant-numeric: tabular-nums; } +.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .skip { - position: absolute; top: 10px; left: var(--gutter); z-index: 100; padding: 10px 14px; border-radius: 8px; - background: var(--text); color: var(--ground); font: 600 14px/1 var(--display); - translate: 0 calc(-100% - 20px); + position: fixed; top: 8px; left: 10px; z-index: 100; padding: 10px 14px; border-radius: 10px; + background: var(--paper); color: var(--text); box-shadow: 0 0 0 2.4px var(--pen); + font: 600 16px/1 var(--hand); text-decoration: none; translate: 0 calc(-100% - 20px); } .skip:focus-visible { translate: none; } -.page { max-width: calc(1800px + var(--gutter) * 2); margin: 0 auto; padding: 0 var(--gutter); } -.num { font-variant-numeric: tabular-nums; } -.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } -p { text-wrap: pretty; } -h1, h2 { text-wrap: balance; } - -/* ---------- Top ---------- */ -.top { display: flex; align-items: center; justify-content: space-between; height: 60px; } -.brand { display: flex; align-items: center; gap: 10px; font: 600 16px/1 var(--display); letter-spacing: -.01em; } -.brand img { width: 26px; height: 26px; } -.nav { display: flex; gap: 18px; font-size: 14px; color: var(--text-2); } -.nav a { padding: 10px 0; } -.nav a:hover { color: var(--text); } -.nav .gh { display: none; } -.nav { align-items: center; } -.mode { display: grid; place-items: center; width: 36px; height: 36px; margin: -8px -10px -8px -4px; border-radius: 50%; color: var(--text-2); } -.mode:hover { color: var(--text); background: var(--line); } -.mode svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; } -.mode .sun, [data-mode="light"] .mode .moon { display: none; } -[data-mode="light"] .mode .sun { display: block; } - -/* ---------- Hero ---------- */ -.hero { padding: 36px 0 34px; } -h1 { - font: 600 clamp(38px, 10.4vw, 62px)/1.02 var(--display); letter-spacing: -.035em; - max-width: 12ch; -} -h1 .wide { display: none; } -.lede { margin-top: 18px; font-size: 18px; line-height: 1.5; color: var(--text-2); max-width: 36ch; } -.get { margin-top: 28px; display: flex; flex-wrap: wrap; align-items: center; gap: 14px 18px; } -.cta { - display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 18px 0 16px; - background: var(--amber); color: var(--amber-ink); border-radius: 9px; - font: 600 15.5px/1 var(--display); letter-spacing: -.005em; - transition: background .15s; -} -.cta:hover { background: #ff9233; } -.cta svg { width: 15px; height: 18px; fill: currentColor; } -.cta .ver { margin-left: -2px; font-weight: 500; font-variant-numeric: tabular-nums; opacity: .75; } -.req { font-size: 13.5px; line-height: 1.45; color: var(--text-3); } -.brew { margin-top: 14px; } -.req code { font: 400 .92em/1 var(--mono); color: var(--text); white-space: nowrap; } - -/* ---------- The desk: the product, at real scale ---------- */ -.desk { - position: relative; margin: 34px calc(var(--gutter) * -1) 0; height: 260px; - isolation: isolate; z-index: 10; -} -.wall { - position: absolute; inset: 0; z-index: -1; overflow: hidden; border-radius: var(--desk-r); background: #f08a24; - -webkit-mask-image: radial-gradient(64% 84% at 78% 0%, #0000009e 0%, #00000075 40%, #0000001f 74%, transparent 100%), linear-gradient(#000 55%, transparent); - -webkit-mask-composite: source-in; - mask-image: radial-gradient(64% 84% at 78% 0%, #0000009e 0%, #00000075 40%, #0000001f 74%, transparent 100%), linear-gradient(#000 55%, transparent); - mask-composite: intersect; -} -.wall::before, .wall::after { content: ""; position: absolute; inset: -60% -10%; background: var(--backdrop) center / cover; scale: 1.4; } -.wall::after { background-image: var(--backdrop-2); opacity: .5; } -@media (prefers-reduced-motion: no-preference) { - .wall::before { animation: grow 44s ease-in-out infinite alternate, turn 69s ease-in-out infinite alternate, wander 57s ease-in-out infinite alternate; } - .wall::after { animation: grow 44s ease-in-out -22s infinite alternate, turn 69s ease-in-out -35s infinite alternate, wander 57s ease-in-out -28s infinite alternate, fade 25s ease-in-out infinite alternate; } + +/* The drawing lives behind the page. Until it paints, or without it, the page sits on its sky. */ +.scene { + position: fixed; left: 0; top: 0; z-index: 0; width: 100%; height: 100vh; height: 100lvh; + background: linear-gradient(var(--sky) 0 61%, var(--sea) 61%); +} +/* The opening (see land() in main.js): a title card over the whole drawing, with the bar big in the middle of it. + The bar then stretches up into its place at the top, the card drifts away and the page comes in. The title is + the page's largest text, so it is also its first large paint. */ +.opening { display: none; } +.intro .opening { + position: fixed; inset: 0; z-index: 60; display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: clamp(16px, 3vh, 32px); padding: 0 28vw 16vh 2vw; pointer-events: none; text-align: center; + --tile-h: 38px; --bar-font: 400 16px/1 var(--mac); --w-radius: 16px; --halo: var(--sky); +} +.op-mark { + font: 800 clamp(76px, 10vw, 136px)/.92 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; letter-spacing: -.012em; + color: #fff; rotate: -1.5deg; -webkit-text-stroke: .045em var(--pen); paint-order: stroke fill; text-shadow: 0 .045em 0 #1111111f; +} +.op-mark { position: relative; isolation: isolate; } +.op-bar { + display: flex; align-items: center; gap: 8px; width: min(780px, 100%); padding: 7px 8px; border-radius: 22px; + background: color-mix(in srgb, #ffffff 36%, transparent); box-shadow: inset 0 0 0 1px #ffffffb3, 0 20px 44px -20px #0b1d4a73; + -webkit-backdrop-filter: blur(10px) saturate(1.3); backdrop-filter: blur(10px) saturate(1.3); +} +.op-bar .w { gap: 8px; padding: 0 13px; } +/* its widgets glow in the lighthouse's weak light at night (main.js sets --glow; none of these is in the card's + transitions, so the light's flicker stays sharp) */ +.opening .op-bar:not(.op-fly) .w { + background-image: linear-gradient(0deg, rgb(255 224 74 / calc(var(--glow, 0) * .55)), rgb(255 224 74 / 0) 82%); + outline: 1.5px solid rgb(255 212 59 / calc(var(--glow, 0) * .8)); outline-offset: 0; + filter: drop-shadow(0 5px 10px rgb(255 212 59 / calc(var(--glow, 0) * .9))); +} +.op-bar svg { width: 18px; height: 18px; } +.op-app { gap: 15px !important; } +.op-app b { font-weight: 700; } +.op-gap { flex: 1; } +/* the card's sun and moon, one over the other: they cross over halfway through the sunrise */ +.op-sun { position: relative; } +.op-sun svg { transition: opacity .45s .55s; } +.op-sun .on { position: absolute; left: 13px; opacity: 0; } +[data-mode="dark"] .op-sun .on { opacity: 1; } +[data-mode="dark"] .op-sun .off { opacity: 0; } +.op-line { font: 600 clamp(18px, 1.7vw, 23px)/1.3 var(--hand); color: var(--pen); text-shadow: 0 0 14px var(--halo), 0 0 4px var(--halo); } +/* the day comes: the card's bar and line change with the sky; their words turn from light to dark quickly, halfway, + where either reads (fading slowly, they would go grey on grey) */ +.opening .op-bar, .opening .w, .op-line { + transition: background-color 1.6s ease-in-out, box-shadow 1.6s ease-in-out, text-shadow 1.6s ease-in-out, color .14s .73s; } -@keyframes grow { from { scale: 1.32; } to { scale: 1.48; } } -@keyframes turn { from { rotate: -12deg; } to { rotate: 12deg; } } -@keyframes wander { from { translate: -6% -6%; } to { translate: 6% 6%; } } -@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +/* asleep: z's rising beside the lighthouse's hat (the page puts them by its lamp), from the night until it wakes */ +.op-z { display: none; position: fixed; z-index: 60; left: -99px; top: -99px; font: 400 var(--zs, 16px)/1 var(--hand); color: #f2eeff; pointer-events: none; transition: opacity .3s, color 1.6s ease-in-out; } +.asleep .op-z { display: block; } +[data-mode="light"] .op-z { color: var(--pen); } +.op-z i { position: absolute; left: 0; top: 0; font-style: normal; opacity: 0; animation: op-z 1.5s linear infinite; } +.op-z i:nth-child(2) { animation-delay: .5s; } +.op-z i:nth-child(3) { animation-delay: 1s; } +.op-z.gone { opacity: 0; } +@keyframes op-z { + 0% { opacity: 0; transform: translate(0, 0) scale(.8) rotate(-9deg); } + 15%, 70% { opacity: 1; } + 100% { opacity: 0; transform: translate(calc(var(--zs) * 1.8), calc(var(--zs) * -3)) scale(1.5) rotate(-9deg); } +} +[data-mode="dark"] .op-line { color: #f2eeff; } +[data-mode="dark"] .op-bar { background: color-mix(in srgb, #0d1030 42%, transparent); box-shadow: inset 0 0 0 1px #ffffff2e, 0 20px 44px -20px #000000a6; } +/* the card's bar on its way up: a copy of it, out of the card, that stretches and shrinks into the real bar */ +.op-fly { position: fixed; margin: 0; transform-origin: 0 0; } +/* behind the card the page waits, and doesn't scroll: the bar and the notes come in once the bar has reached the top */ +.intro { scrollbar-gutter: stable; } +.intro body { overflow: hidden; } +.intro .dock { opacity: 0; pointer-events: none; } +.intro .hero > * { opacity: 0; translate: 0 14px; pointer-events: none; } +.intro.op-bar-in .dock { opacity: 1; transition: opacity .3s; } +.intro.op-page-in .hero > * { opacity: 1; translate: none; transition: opacity .45s, translate .5s var(--ease); } +.intro-skip .dock, .intro-skip .hero > * { transition: none !important; } +@media (max-width: 699.98px), (max-aspect-ratio: 10/11) { + /* on phones the card sits low, over the sea under the lighthouse, its title on two lines */ + .intro .opening { justify-content: flex-end; padding: 0 4vw 10vh; --tile-h: 36px; --halo: #14184acc; } + .op-mark { font-size: clamp(64px, 21vw, 112px); line-height: .86; } + .op-mark .op-w1, .op-mark .op-w2 { display: block; } + .op-line { color: #fff; } + .op-app span, .op-snd, .opening .clock .day { display: none; } + .op-bar { width: auto; min-width: min(330px, 92vw); } +} +.scene canvas { display: block; } +main { position: relative; z-index: 1; overflow-x: clip; } +/* past the last note, room to scroll into the drawing (see the dive in main.js) */ +.dive { height: 1800vh; } + +/* ---------- The bar, drawn as the app draws it ---------- */ /* Theme tokens, from each theme's config.json in Resources/themes */ -[data-theme] { --strip-bg: transparent; --strip-edge: none; --strip-radius: 0px; --strip-m: 0px; --w-bg: #242629ee; --w-border: #ffffff1f; --w-shadow: #00000040; --w-glow: none; --w-radius: 14px; --tile-h: 28px; --fg: #f5f5f7; --muted: #a1a1a6; --accent: #0a84ff; --accent-text: #ffffff; --ws-off: #f5f5f7; --ws-ind: #0a84ff; --ws-on: #ffffff; --ws-r: 10px; --pop-bg: #242629ee; --pop-border: #ffffff1f; --pop-radius: 14px; --raised: #36383c; --track: #f5f5f726; --bar-font: 400 13px/1 var(--mac); --pop-font: 400 13px/16px var(--mac); } +[data-theme] { --rest-h: 40px; --strip-bg: transparent; --strip-edge: none; --strip-radius: 0px; --strip-m: 0px; --w-bg: #242629ee; --w-border: #ffffff1f; --w-shadow: #00000040; --w-glow: none; --w-radius: 14px; --tile-h: 28px; --fg: #f5f5f7; --muted: #a1a1a6; --accent: #0a84ff; --accent-text: #ffffff; --ws-off: #f5f5f7; --ws-ind: #0a84ff; --ws-on: #ffffff; --ws-r: 10px; --pop-bg: #242629ee; --pop-border: #ffffff1f; --pop-radius: 14px; --raised: #36383c; --track: #f5f5f726; --bar-font: 400 13px/1 var(--mac); --pop-font: 400 13px/16px var(--mac); } [data-theme] { --tray-tint: color-mix(in srgb, var(--fg) 7%, transparent); --tray-line: color-mix(in srgb, var(--fg) 12%, transparent); --tray-backing: transparent; } [data-theme="built-in"] { --tray-backing: #2b2b2b; } [data-mode="light"] [data-theme="built-in"] { --tray-backing: #f7f7f7; } @@ -165,42 +200,47 @@ h1 .wide { display: none; } [data-mode="light"] [data-theme="gruvbox"] { --strip-bg: linear-gradient(180deg, #fbf1c7ee 0%, #fbf1c7ee 79%, #cc241d 79%, #cc241d 86%, #d65d0e 86%, #d65d0e 93%, #d79921 93%, #d79921 100%); --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #3c3836; --muted: #7c6f64; --accent: #af3a03; --accent-text: #fbf1c7; --ws-off: #7c6f64; --ws-ind: transparent; --ws-on: #af3a03; --pop-bg: #fbf1c7ee; --pop-border: #3c38361f; --raised: #ebdbb2; --track: #d5c4a1; } [data-theme="leman"] { --strip-bg: #0f1d21ee; --strip-edge: inset 0 0 0 1px #dcebe81f; --strip-radius: 14px; --strip-m: 6px; --tile-h: 22px; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 10px; --fg: #dcebe8; --muted: #8fb0ad; --accent: #5cc5b8; --accent-text: #0f1d21; --ws-off: #8fb0ad; --ws-ind: #5cc5b829; --ws-on: #5cc5b8; --ws-r: 7px; --pop-bg: #0f1d21ee; --pop-border: #dcebe81f; --pop-radius: 10px; --raised: #1d3238; --track: #dcebe826; --bar-font: 400 13px/1 var(--mac); } [data-mode="light"] [data-theme="leman"] { --strip-bg: #edf3f2ee; --strip-edge: inset 0 0 0 1px #12343b1f; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #12343b; --muted: #4d6b70; --accent: #0e7c7b; --accent-text: #ffffff; --ws-off: #4d6b70; --ws-ind: #0e7c7b1f; --ws-on: #0e7c7b; --pop-bg: #edf3f2ee; --pop-border: #12343b1f; --track: #12343b26; } -[data-theme="nord"] { --bar-h: 30px; --strip-bg: #2e3440ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 5px; --fg: #eceff4; --muted: #a3acbd; --accent: #88c0d0; --accent-text: #2e3440; --ws-off: #a3acbd; --ws-ind: #88c0d0; --ws-on: #eceff4; --ws-r: 1.5px; --pop-bg: #2e3440ee; --pop-border: #eceff41f; --pop-radius: 8px; --raised: #3b4252; --track: #4c566a; --bar-font: 500 12px/1 "Avenir Next", var(--mac); --pop-font: 500 12px/16px "Avenir Next", var(--mac); } +[data-theme="nord"] { --rest-h: 30px; --bar-h: 30px; --strip-bg: #2e3440ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 5px; --fg: #eceff4; --muted: #a3acbd; --accent: #88c0d0; --accent-text: #2e3440; --ws-off: #a3acbd; --ws-ind: #88c0d0; --ws-on: #eceff4; --ws-r: 1.5px; --pop-bg: #2e3440ee; --pop-border: #eceff41f; --pop-radius: 8px; --raised: #3b4252; --track: #4c566a; --bar-font: 500 12px/1 "Avenir Next", var(--mac); --pop-font: 500 12px/16px "Avenir Next", var(--mac); } [data-mode="light"] [data-theme="nord"] { --strip-bg: #eceff4ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #2e3440; --muted: #4c566a; --accent: #5e81ac; --accent-text: #eceff4; --ws-off: #4c566a; --ws-ind: #5e81ac; --ws-on: #2e3440; --pop-bg: #eceff4ee; --pop-border: #2e34401f; --raised: #d8dee9; --track: #d8dee9; } [data-theme="notch"] { --strip-bg: #000000; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-radius: 6px; --muted: #8e8e93; --accent: #64d2ff; --accent-text: #000000; --ws-off: #8e8e93; --ws-ind: transparent; --ws-on: #f5f5f7; --ws-r: 0px; --pop-bg: #000000; --pop-border: #ffffff1a; --pop-radius: 6px; --raised: #1c1c1e; --track: #ffffff26; --bar-font: 500 13px/1 var(--mac); --pop-font: 500 13px/16px var(--mac); } [data-mode="light"] [data-theme="notch"] { --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --fg: #f5f5f7; --muted: #8e8e93; --accent: #64d2ff; --ws-off: #8e8e93; --ws-ind: transparent; --pop-bg: #000000; --pop-border: #ffffff1a; --raised: #1c1c1e; --track: #ffffff26; } [data-theme="phosphor"] { --tile-h: 27px; --strip-bg: #110d06ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-glow: drop-shadow(0 0 4px #ffb00080); --w-radius: 0px; --fg: #ffb000; --muted: #b8800c; --accent: #ffcc5c; --accent-text: #110d06; --ws-off: #b8800c; --ws-ind: #ffb000; --ws-on: #110d06; --ws-r: 0px; --pop-bg: #110d06ee; --pop-border: #ffb00059; --pop-radius: 0px; --raised: #2b2009; --track: #ffb00033; --bar-font: 400 12px/1 "Monaco", var(--mono); --pop-font: 400 12px/15px "Monaco", var(--mono); } [data-mode="light"] [data-theme="phosphor"] { --strip-bg: #f6f1e6ee; --w-bg: transparent; --w-border: transparent; --w-shadow: transparent; --w-glow: none; --fg: #5c3a00; --muted: #87683b; --accent: #a86400; --accent-text: #f6f1e6; --ws-off: #87683b; --ws-ind: #5c3a00; --ws-on: #f6f1e6; --pop-bg: #f6f1e6ee; --pop-border: #5c3a0040; --raised: #ebe2cf; --track: #5c3a0026; } -/* The dock holds the bar and its popups. It sits at the top of the desk, - stays at the top of the window once the desk reaches it, and becomes - the page's header once the desk has scrolled away. */ -.dock { position: absolute; top: 0; left: 0; right: 0; height: var(--bar-h); z-index: 30; } -.dock.docked { position: fixed; z-index: 50; --desk-r: 0px; } +/* The dock holds the bar and its popups at the top of the window, for the whole page. + Once the page scrolls, a strip of sky slides in behind it so text never runs under its widgets. */ +.dock { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: var(--bar-h); } +[data-mode="dark"] .dock { color-scheme: dark; --ring: #ffe04a; } .dock::before { - content: ""; position: absolute; inset: 0; z-index: -1; - background: var(--ground); box-shadow: 0 1px 0 var(--line); - opacity: 0; transition: opacity .3s; + content: ""; position: absolute; inset: 0 0 -7px; z-index: -1; pointer-events: none; + background: var(--rule) left bottom / 160px 10px repeat-x, linear-gradient(var(--strip) 0 0) top / 100% calc(100% - 5px) no-repeat; + opacity: 0; translate: 0 -6px; transition: opacity .25s, translate .3s var(--ease); } -.dock.header::before { inset: 0 -100vw; opacity: 1; } +/* The strip is the colour of the sky behind it (set by main.js); its pen line turns light when the sky is dark */ +[data-mode="dark"] .dock::before, .dock[data-sky="dark"]::before { background-image: var(--rule-night), linear-gradient(var(--strip) 0 0); } +.dock[data-sky="light"]::before { background-image: var(--rule), linear-gradient(var(--strip) 0 0); } +.dock.scrolled::before { opacity: 1; translate: none; } .bar { position: absolute; top: 0; left: 0; right: 0; height: calc(var(--bar-h) - 2 * var(--strip-m)); - margin: 0 var(--strip-m); padding: 0 12px; margin-top: var(--strip-m); + margin: var(--strip-m) var(--strip-m) 0; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--fg); font: var(--bar-font); -webkit-font-smoothing: auto; - background: var(--strip-bg); box-shadow: var(--strip-edge); - border-radius: max(var(--strip-radius), var(--desk-r)) max(var(--strip-radius), var(--desk-r)) var(--strip-radius) var(--strip-radius); + background: var(--strip-bg); box-shadow: var(--strip-edge); border-radius: var(--strip-radius); transition: background .25s, border-radius .25s, margin .25s var(--ease), height .25s var(--ease); } .bar .g { display: flex; align-items: center; gap: 6px; min-width: 0; } +/* Becoming the header: the widgets fade out, swap in one step and fade back in (see dockState in main.js) */ +.bar .g { transition: opacity .13s ease; } +.bar.swap .g { opacity: 0; } .w { flex: none; height: var(--tile-h); display: flex; align-items: center; gap: 7px; padding: 0 10px; white-space: nowrap; background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); - filter: var(--w-glow); cursor: default; user-select: none; transition: background .15s, border-radius .25s; + filter: var(--w-glow); color: var(--fg); font: var(--bar-font); text-decoration: none; cursor: default; user-select: none; + transition: background .15s, border-radius .25s, box-shadow .2s; } -.w[data-pop], .w.app, .w.more { cursor: pointer; } -.w[data-pop]:hover, .w.more:hover { background: var(--raised); } +.w[data-pop], .w.app, .w.more, .w.moon { cursor: pointer; } +.w[data-pop]:hover, .w.more:hover, .w.moon:hover { background: var(--raised); } .w.over, .w[hidden] { display: none !important; } .w svg { width: 15px; height: 15px; flex: none; } .w.apple { padding: 0 9px; font-size: 15px; } @@ -220,6 +260,7 @@ h1 .wide { display: none; } .menus { display: grid; grid-template-columns: 0fr; transition: grid-template-columns .3s var(--ease); } .menus > span { overflow: hidden; display: flex; gap: 14px; } .menus > span > :first-child { margin-left: 6px; } +.menus a { text-decoration: none; } .app.expand .menus { grid-template-columns: 1fr; } .w.more { padding: 0 9px; letter-spacing: .08em; } /* Sound: hovering it opens a volume slider before the icon, as on the right of the real bar */ @@ -233,6 +274,11 @@ h1 .wide { display: none; } .snd input::-webkit-slider-thumb { appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 .5px #0000004d, 0 1px 2px #00000059; } .snd input::-moz-range-thumb { width: 13px; height: 13px; border: 0; border-radius: 50%; background: #fff; box-shadow: 0 0 0 .5px #0000004d, 0 1px 2px #00000059; } .snd .pct { min-width: 4ch; text-align: right; } +/* The night switch: a script widget with an icon and an action, as the app can draw one */ +.w.moon { padding: 0 9px; } +.w.moon svg { width: 16px; height: 16px; } +.moon .on, .moon[aria-pressed="true"] .off { display: none; } +.moon[aria-pressed="true"] .on { display: block; } /* Now playing: artwork, the track and an equalizer, as on the real bar */ .art { width: 16px; height: 16px; flex: none; border-radius: 3px; background: linear-gradient(150deg, #241a5c 0%, #6b2a8c 42%, #e0566a 72%, #f6a04d 100%); } @@ -258,20 +304,45 @@ h1 .wide { display: none; } .header .w.site { display: flex; } .site .menus a { color: var(--fg); } .site .menus a:hover { color: var(--accent); } -.header .site .menus { grid-template-columns: 1fr; transition-delay: .1s; } -@starting-style { .header .site .menus { grid-template-columns: 0fr; } } +.header .site .menus { grid-template-columns: 1fr; } +/* The header's Download is the page's own button at bar size: the highlighter yellow, the display hand + and a felt-pen outline printed a little off it (drawn by main.js, as on the page's other buttons) */ .w.dl { - padding: 0; display: grid; grid-template-columns: 0fr; margin-left: -6px; opacity: 0; visibility: hidden; - background: var(--amber); box-shadow: none; color: var(--amber-ink); font-weight: 600; - transition: grid-template-columns .4s var(--ease), margin .4s var(--ease), opacity .3s, visibility 0s .4s; -} -.w.dl > span { overflow: hidden; display: flex; align-items: center; gap: 6px; padding: 0; transition: padding .4s var(--ease); } -.w.dl svg { width: 16px; height: 16px; } -.w.dl:hover { background: #ff9233; } -.header .w.dl { grid-template-columns: 1fr; margin-left: 0; opacity: 1; visibility: visible; transition-delay: 0s; } -.header .w.dl > span { padding: 0 11px 0 8px; } + --ox: -2px; --oy: -2px; + position: relative; isolation: isolate; padding: 0; display: grid; grid-template-columns: 0fr; margin-left: -6px; opacity: 0; visibility: hidden; + border-radius: 999px; background: var(--hi); box-shadow: none; filter: none; color: #111; + font: 800 13.5px/1 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; letter-spacing: .01em; + transition: rotate .2s var(--ease), background-color .2s; +} +.w.dl > span { overflow: hidden; display: flex; align-items: center; gap: 6px; padding: 0; } +.w.dl svg:not(.pen) { width: 16px; height: 16px; } +.w.dl:hover { background: #ffb547; rotate: -2deg; } +.header .w.dl { grid-template-columns: 1fr; margin-left: 2px; opacity: 1; visibility: visible; } +.header .w.dl > span { padding: 0 12px 0 8px; } .measure * { transition: none !important; } +/* The lamp-lit tour: the widget under the pointer glows as if the lamp lit it from below, + and a paper tag hangs under it on a doodled string (placed by main.js) */ +#bar .w.lamp { background-image: linear-gradient(0deg, #ffe04a80, #ffe04a00 82%); box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 1.5px #ffd43bb3, 0 6px 14px -2px #ffd43bf0; } +#bar .w.dl.lamp { background-image: none; box-shadow: 0 6px 14px -2px #ffd43bf0; } +/* the landing's sweep: each widget glows as the light passes over it */ +#bar .w.swept { background-image: linear-gradient(0deg, #ffe04a80, #ffe04a00 82%); box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 1.5px #ffd43bb3, 0 6px 14px -2px #ffd43bf0; } +.lamp-tag { position: absolute; left: 0; top: 0; z-index: 4; width: 0; height: 0; pointer-events: none; transform-origin: 0 0; } +.lamp-tag[hidden] { display: none; } +.lt-s { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; } +.lt-s path { fill: none; stroke-linecap: round; } +.lt-s .h { stroke: #fff; stroke-width: 4.4; } +.lt-s .k { stroke: var(--pen); stroke-width: 1.9; } +.lt-p { + --ox: -3px; --oy: -2px; + position: absolute; left: 0; top: 0; isolation: isolate; display: block; padding: 12px 12px 7px; white-space: nowrap; + background: var(--paper); color: var(--text); border-radius: 7px 10px 6px 11px / 10px 6px 11px 7px; + font: 600 15px/1.15 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; + rotate: var(--r, -1deg); transform-origin: var(--hole, 50%) 7px; +} +/* the punched hole the string goes through, the sky showing in it */ +.lt-p::after { content: ""; position: absolute; left: var(--hole, 50%); top: 4px; width: 7px; height: 7px; margin-left: -3.5px; border-radius: 50%; background: var(--strip); box-shadow: 0 0 0 1.6px var(--pen); } + /* The center position shows only when a widget sits there */ .dock:not(.editing) #gc:not(:has(> .w:not([hidden]))) { display: none; } @@ -287,20 +358,21 @@ h1 .wide { display: none; } .lane.fl { mask-image: linear-gradient(90deg, transparent, #000 18px); } .lane.fr { mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); } .lane.fl.fr { mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent); } -.tile { position: relative; flex: none; display: flex; isolation: isolate; border-radius: 5px; cursor: grab; touch-action: none; user-select: none; } +.tile { position: relative; flex: none; display: flex; isolation: isolate; border-radius: 5px; user-select: none; } +.lane .tile { cursor: grab; touch-action: none; } .tile::before { content: ""; position: absolute; inset: -2px; z-index: -1; border-radius: 5px; background: var(--fg); opacity: 0; transition: opacity .1s; } -.tile:hover::before { opacity: .08; } +.lane .tile:hover::before { opacity: .08; } .tile:focus-visible { outline-offset: 2px; } .tile[hidden] { display: none; } .tile.lifted { opacity: 0; } -.tile .w { cursor: grab; } +.lane .tile .w { cursor: grab; } .minus { position: absolute; left: -5px; top: -5px; z-index: 2; display: grid; place-items: center; width: 14px; height: 14px; border-radius: 50%; background: #6e6e73; transition: opacity .15s; } .minus::before { content: ""; width: 7px; height: 1.8px; border-radius: 1px; background: #fff; } .add { flex: none; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 5px; color: var(--fg); border: 1.5px dashed color-mix(in srgb, var(--fg) 55%, transparent); transition: opacity .15s; } .add svg { width: 12px; height: 12px; } -.add.on { background: var(--sys-blue); border-color: var(--sys-blue); color: #fff; } +.add.on { background: var(--blue-fill); border-color: var(--blue-fill); color: #fff; } .chip { position: absolute; left: 5px; top: -5px; z-index: 3; padding: 1px 4px; border-radius: 99px; font: 700 9px/1.25 var(--mac); color: var(--fg); background: linear-gradient(var(--tray-tint) 0 0) var(--pop-bg); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--fg) 15%, transparent); opacity: 0; transition: opacity .15s; pointer-events: none; } -.g.target .chip { background: var(--sys-blue); color: #fff; } +.g.target .chip { background: var(--blue-fill); color: #fff; } .dragging .chip { opacity: 1; } .dragging .minus { opacity: 0; pointer-events: none; } .dragging .g.target .add { opacity: 0; } @@ -313,24 +385,22 @@ h1 .wide { display: none; } .gauge i { height: 5px; border-radius: 3px; background: linear-gradient(currentColor 0 0) 0 / var(--p) 100% no-repeat, var(--track); } .gauge i + i { height: 3px; } -/* The panel below the bar: Edit Bar in Place, then a hint, Undo and Done while editing */ +/* Below the bar while editing: a hint, Undo and Done */ .edit-ctl { - position: absolute; top: calc(100% + 12px); left: calc(var(--strip-m) + 6px); z-index: 5; - display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; white-space: nowrap; + position: absolute; top: calc(100% + 12px); left: 50%; z-index: 5; translate: -50% 0; + display: none; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; white-space: nowrap; color: var(--win-fg); font: 400 13px/1 var(--mac); background: color-mix(in srgb, var(--win-bg) 84%, transparent); -webkit-backdrop-filter: blur(24px) saturate(1.5); backdrop-filter: blur(24px) saturate(1.5); box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 14px 34px -14px var(--shadow-2); } -.edit-ctl button { height: 22px; padding: 0 8px; border-radius: 5px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); } -[data-mode="light"] .edit-ctl button { background: color-mix(in srgb, var(--win-fg) 7%, transparent); } +.editing .edit-ctl { display: flex; } +.adding .edit-ctl { display: none; } +.edit-ctl button { height: 22px; padding: 0 8px; border-radius: 5px; background: color-mix(in srgb, var(--win-fg) 7%, transparent); } +[data-mode="dark"] .edit-ctl button { background: color-mix(in srgb, var(--win-fg) 12%, transparent); } .edit-ctl button:hover { background: color-mix(in srgb, var(--win-fg) 18%, transparent); } -.edit-ctl .done, [data-mode="light"] .edit-ctl .done { background: var(--sys-blue); color: #fff; } +.edit-ctl .done, [data-mode="dark"] .edit-ctl .done { background: var(--blue-fill); color: #fff; } .edit-ctl button:disabled { opacity: .45; cursor: default; background: color-mix(in srgb, var(--win-fg) 12%, transparent); } .edit-ctl .hint { font-size: 11.5px; color: color-mix(in srgb, var(--win-fg) 62%, transparent); } -.editing .edit-ctl { left: 50%; translate: -50% 0; } -.edit-ctl .editing-hint, .edit-ctl .b, .editing .edit-ctl :is(.start, .idle) { display: none; } -.editing .edit-ctl :is(.editing-hint, .b) { display: inline-block; } -.header .edit-ctl, .adding .edit-ctl, .docked:not(.editing) .edit-ctl { display: none; } /* Add here: built-in widgets and the Store's packages, then a package's review */ .addpop { @@ -340,29 +410,29 @@ h1 .wide { display: none; } } .addpop.review { width: 400px; } .addpop::before { content: ""; position: absolute; top: -6px; left: var(--ax, 24px); width: 12px; height: 12px; rotate: 45deg; background: var(--win-bg); border-radius: 2px 0 0; box-shadow: -1px -1px 0 -.5px var(--win-edge); } -.addpop .t { margin-bottom: 10px; font: 700 10px/1 var(--mac); color: color-mix(in srgb, var(--win-fg) 55%, transparent); letter-spacing: .02em; } +.addpop .t { margin-bottom: 10px; font: 700 10px/1 var(--mac); color: color-mix(in srgb, var(--win-fg) 68%, transparent); letter-spacing: .02em; } .addpop .find { width: 100%; height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: color-mix(in srgb, var(--win-fg) 8%, transparent); color: inherit; font: 400 13px/1 var(--mac); } .addpop .find:focus-visible { outline: 3px solid color-mix(in srgb, var(--sys-blue) 55%, transparent); outline-offset: 0; } -.addpop .find::placeholder { color: color-mix(in srgb, var(--win-fg) 50%, transparent); } +.addpop .find::placeholder { color: color-mix(in srgb, var(--win-fg) 62%, transparent); } .addpop .list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: 296px; margin-top: 10px; overflow-y: auto; overscroll-behavior: contain; } .addpop .ch { display: flex; align-items: center; gap: 7px; height: 44px; min-width: 0; padding: 0 8px; border-radius: 6px; text-align: left; } .addpop .ch:hover, .addpop .ch:focus-visible { background: color-mix(in srgb, var(--sys-blue) 18%, transparent); outline: none; } .addpop .ch .ic { width: 15px; height: 15px; flex: none; margin: 0 1.5px; } .addpop .ch span { min-width: 0; } .addpop .ch b { display: block; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.addpop .ch small { display: block; margin-top: 1px; font-size: 10px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.addpop .ch small { display: block; margin-top: 1px; font-size: 10px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); } .addpop .ch .from-store { width: 11px; height: 11px; flex: none; margin-left: auto; } -.addpop .none { padding: 10px 2px 2px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.addpop .none { padding: 10px 2px 2px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); } .addpop .back { height: 22px; padding: 0 8px; border-radius: 5px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); } .addpop h4 { margin-top: 12px; font: 600 15px/1.25 var(--mac); } .addpop .d { margin-top: 4px; color: color-mix(in srgb, var(--win-fg) 78%, transparent); } -.addpop .m { margin-top: 4px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.addpop .m { margin-top: 4px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); } .addpop .can { list-style: none; margin-top: 12px; padding: 2px 10px; border-radius: 8px; background: color-mix(in srgb, var(--win-fg) 5%, transparent); } .addpop .can li { display: flex; align-items: center; gap: 8px; padding: 7px 0; } .addpop .can li + li { border-top: 1px solid color-mix(in srgb, var(--win-fg) 8%, transparent); } .addpop .can svg { width: 14px; height: 14px; flex: none; color: color-mix(in srgb, var(--win-fg) 60%, transparent); } -.addpop .foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); font-size: 11px; } -.addpop .go { flex: none; margin-left: auto; height: 26px; padding: 0 12px; border-radius: 6px; background: var(--sys-blue); color: #fff; font-size: 13px; } +.addpop .foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: color-mix(in srgb, var(--win-fg) 68%, transparent); font-size: 11px; } +.addpop .go { flex: none; margin-left: auto; height: 26px; padding: 0 12px; border-radius: 6px; background: var(--blue-fill); color: #fff; font-size: 13px; } /* The bar's context menu, in the system's style */ .ctx { @@ -372,12 +442,12 @@ h1 .wide { display: none; } box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 12px 30px -10px var(--shadow-2); } .ctx button { display: block; width: 100%; padding: 5px 10px; border-radius: 5px; text-align: left; } -.ctx button:hover, .ctx button:focus-visible { background: var(--sys-blue); color: #fff; outline: none; } +.ctx button:hover, .ctx button:focus-visible { background: var(--blue-fill); color: #fff; outline: none; } .ctx hr { height: 1px; margin: 5px 10px; border: 0; background: color-mix(in srgb, var(--win-fg) 14%, transparent); } /* Popups and the More widgets menu */ .pop { - position: absolute; z-index: 6; width: 300px; padding: 12px 14px 11px; + position: absolute; z-index: 6; width: 300px; background: var(--pop-bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--pop-border), 0 24px 60px -24px var(--shadow-2); border-radius: var(--pop-radius); font: var(--pop-font); -webkit-font-smoothing: auto; opacity: 0; visibility: hidden; transform: translateY(-4px); @@ -399,19 +469,9 @@ h1 .wide { display: none; } .pop .meter i { display: block; height: 100%; border-radius: 2px; background: var(--accent); } .pop .graph { display: block; width: 100%; height: 44px; margin: 2px 0 4px; } .pop .graph polyline { fill: none; stroke: var(--accent); stroke-width: 1.6; vector-effect: non-scaling-stroke; } -.ctl { display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 8px; padding: 5px 0; } -.ctl input[type=range] { width: 100%; accent-color: var(--accent); } -.check { display: flex; align-items: center; gap: 8px; padding: 5px 0; cursor: pointer; } -.check input { accent-color: var(--accent); width: 14px; height: 14px; } -.seg { display: flex; background: var(--track); border-radius: 7px; padding: 2px; } -.seg button { flex: 1; padding: 4px 0; border-radius: 5px; font: inherit; } -.seg button.on { background: var(--raised); } -.search { width: 100%; margin-top: 8px; padding: 6px 9px; border-radius: 7px; border: 0; background: var(--track); color: var(--fg); font: inherit; } -.search::placeholder { color: var(--muted); } -.apps div { padding: 4px 0; } .menu { width: 250px; padding: 6px; } .menu p { color: var(--muted); padding: 6px 8px 4px; font: 500 11.5px/1 var(--mac); } -.menu button, .menu a { display: flex; width: 100%; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 6px; text-align: left; font: inherit; } +.menu button, .menu a { display: flex; width: 100%; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 6px; text-align: left; font: inherit; text-decoration: none; } .menu button:hover, .menu a:hover { background: var(--accent); color: var(--accent-text); } .pane[data-id="site"] { width: 200px; } .np-pop .src { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--muted); font-size: 12px; } @@ -437,291 +497,382 @@ h1 .wide { display: none; } .menu button svg { width: 15px; height: 15px; flex: none; } .menu button.over-off { display: none; } -/* Windows. On the desk, drag them by the title bar. */ -.layer { - position: absolute; z-index: 4; - transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0) rotate(var(--tilt, 0deg)); -} -.win { - border-radius: 12px; background: var(--win-bg); color: var(--win-fg); font: 400 13px/1.3 var(--mac); - box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 40px 90px -30px var(--shadow-1), 0 12px 30px -16px var(--shadow-2); - transition: box-shadow .2s; -} -.win .tb { position: relative; display: flex; align-items: center; gap: 14px; padding: 15px 16px 10px; } -.win .tb .title { position: absolute; left: 0; right: 0; text-align: center; color: var(--text-3); font-size: 12px; pointer-events: none; } -.layer .tb { cursor: grab; touch-action: none; user-select: none; } -.layer.dragging .tb { cursor: grabbing; } -.layer.dragging .win { box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 60px 120px -30px var(--shadow-1), 0 20px 44px -16px var(--shadow-2); } -.tl { display: flex; gap: 8px; } -.tl i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; } -.tl i:nth-child(2) { background: #febc2e; } .tl i:nth-child(3) { background: #28c840; } -.theme-layer { left: 16px; bottom: 16px; width: min(358px, calc(100% - 32px)); } -.theme-win .tb b { font-weight: 600; } -.theme-win .tb .save { margin-left: auto; color: var(--sys-blue); font-size: 12.5px; white-space: nowrap; } -.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 206px; overflow-y: auto; overscroll-behavior: contain; padding: 2px 14px 14px; } -.card { min-width: 0; overflow: hidden; padding: 5px 5px 7px; border-radius: 9px; background: var(--card-bg); box-shadow: inset 0 0 0 1px var(--card-edge); text-align: left; transition: box-shadow .15s; } -.card:hover { box-shadow: inset 0 0 0 1px var(--card-hover); } -.card.on { box-shadow: inset 0 0 0 2px var(--sys-blue); } -.card .shot { position: relative; height: 40px; border-radius: 5px; overflow: hidden; background: var(--dim), var(--backdrop) center / cover; } -.card .nm { display: flex; justify-content: space-between; align-items: center; margin: 6px 3px 0; font-size: 12px; font-weight: 600; } -.card .nm svg { width: 12px; height: 12px; stroke: var(--sys-blue); fill: none; stroke-width: 2.2; opacity: 0; } -.card.on .nm svg { opacity: 1; } -.term-layer { display: none; } -.win.term { --win-bg: #100e0c; --win-fg: #d9cfc2; --win-edge: #ffffff17; --text-3: #a09484; } -.term pre { padding: 2px 8px 14px; font: 400 12.5px/1.5 var(--mono); } -.term button { display: block; width: 100%; text-align: left; padding: 3px 8px; border-radius: 5px; font: inherit; white-space: pre; position: relative; } -.term button::after { content: "Run ↵"; content: "Run ↵" / ""; position: absolute; right: 8px; color: var(--amber); font-family: var(--body); font-size: 11.5px; opacity: 0; transition: opacity .15s; } -.term button:hover { background: #f5821f14; } -.term button:hover::after, .term button.ran::after { opacity: 1; } -.term button.ran::after { content: "✓"; content: "✓" / ""; } -.term .p, .term .out { color: var(--text-3); } -.term .out { padding: 0 8px 4px; } - -/* Small sample bars in the theme cards and the theme list */ +/* ---------- Paper ---------- */ + +/* A note: white paper with an irregular corner, a felt-pen line printed a little off it, and tape. + main.js draws each line as an SVG (.pen) in three states at the note's size, and the CSS steps between them. */ +.note { + --rot: -.6deg; --ox: -5px; --oy: -3px; + position: relative; isolation: isolate; background: var(--paper); color: var(--text); + padding: 32px 34px 30px; border-radius: 16px 24px 14px 28px / 24px 14px 28px 16px; rotate: var(--rot); +} +.note::after { + content: ""; position: absolute; top: -14px; left: var(--tape-x, 50%); width: var(--tape-w, 118px); height: 30px; z-index: 1; + translate: -50% 0; rotate: var(--tape-r, -3deg); background: var(--tape); pointer-events: none; + clip-path: polygon(0 6%, 4% 0, 8% 8%, 13% 2%, 18% 7%, 50% 3%, 82% 6%, 88% 0, 93% 7%, 97% 1%, 100% 8%, 99% 92%, 95% 100%, 90% 93%, 85% 100%, 50% 96%, 15% 99%, 10% 92%, 5% 100%, 1% 94%); +} +/* The pen line before main.js draws it, or without it */ +:where(.note, .cta, .tag, .keys button, .lt-p)::before { + content: ""; position: absolute; z-index: -1; pointer-events: none; + inset: var(--oy) calc(-1 * var(--ox)) calc(-1 * var(--oy)) var(--ox); + border: 2.4px solid var(--pen); border-radius: inherit; +} +.inked::before { content: none; } +.inked > .pen { + position: absolute; z-index: -1; left: calc(var(--ox) - 8px); top: calc(var(--oy) - 8px); width: calc(100% + 16px); height: calc(100% + 16px); + margin: 0 !important; overflow: visible; pointer-events: none; fill: var(--pen); +} +.pen path { visibility: hidden; } +.pen .p0 { visibility: visible; } +/* About seven states a second, the same everywhere; at rest after a few quiet seconds */ +@media (prefers-reduced-motion: no-preference) { + .pen path { animation: boil .429s steps(1, end) infinite; } + .pen .p1 { animation-delay: -.286s; } + .pen .p2 { animation-delay: -.143s; } + .idle .pen path { animation-play-state: paused; } +} +@keyframes boil { 0% { visibility: visible; } 33.333%, 100% { visibility: hidden; } } + +/* Type on paper */ +h1, h2 { font-family: var(--display); font-weight: 800; font-variation-settings: "INFM" 70, "BNCE" 25; letter-spacing: -.01em; color: var(--text); } +h1 { font-size: clamp(40px, 4.2vw, 62px); line-height: 1.04; rotate: -1deg; } +h2 { font-size: clamp(30px, 3vw, 44px); line-height: 1.06; } +h3 { font: 800 22px/1.2 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; } +.note > * + * { margin-top: 14px; } +.note h2 + p { margin-top: 12px; } +.note a:not(.cta) { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } +.note a:not(.cta):hover { text-decoration-color: currentColor; } +.note strong { font-weight: 700; } +.lede { font-size: 1.15em; line-height: 1.45; } +.req { font-size: 15px; line-height: 1.45; color: var(--text-2); } +.links { display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 16px; } +.cap { + width: fit-content; max-width: 100%; color: var(--red); rotate: -2deg; transform-origin: left center; + font: 600 18px/1.3 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; +} +.cap small { font-size: .78em; color: var(--text); } +.note .cap { margin-top: 22px; } + +/* Words the beam crosses get a highlighter swipe; at rest the headline has none */ +.lumen { position: relative; z-index: 0; } +.lumen::before { + content: ""; position: absolute; inset: 18% -.08em 4% -.1em; z-index: -1; + background: var(--highlight) 0 0 / 100% 100% no-repeat; clip-path: inset(0 100% 0 0); + transition: clip-path .28s var(--ease); +} +.lumen.lit::before { clip-path: inset(0); } + +/* Download: Shantell on the lamp's yellow, in a pen line */ +.get { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px; } +.note .get { margin-top: 24px; } +.cta { + --ox: -4px; --oy: -3px; + position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 10px; flex: none; + min-height: 54px; padding: 12px 26px 12px 22px; border-radius: 999px; background: var(--hi); color: #111; + font: 800 20px/1 var(--display); font-variation-settings: "INFM" 70, "BNCE" 25; text-decoration: none; white-space: nowrap; + transition: rotate .2s var(--ease), background-color .2s; +} +.cta:hover { background: #ffb547; rotate: -2deg; } +.cta svg { width: 16px; height: 19px; flex: none; fill: currentColor; } +.cta .ver { margin-left: -2px; opacity: .72; font-variant-numeric: tabular-nums; } + +/* ---------- The hero: the headline note and the key ---------- */ +.hero { + --left: 5vw; + position: relative; min-height: 100vh; min-height: 100svh; display: flex; flex-direction: column; align-items: flex-start; gap: 22px; + padding: calc(var(--bar-h) + clamp(26px, 6.5vh, 70px)) 0 clamp(18px, 3.4vh, 34px) var(--left); +} +.hero-note { width: min(46vw, 620px); --rot: -.7deg; --tape-x: 38%; padding: 34px 38px 26px; } +.hero-note .lede { margin-top: 16px; } +.hero-note .get { margin-top: 22px; } +.hero-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 18px; } +.note .hero-foot { margin-top: 18px; } +.quiet { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 15.5px; } +.hero-foot .cap { margin-top: 0; } + +.key { --rot: .8deg; --tape-x: 16%; --tape-w: 66px; --tape-r: 6deg; margin-top: auto; width: fit-content; max-width: min(562px, 46vw); padding: 14px 18px 12px; border-radius: 12px 18px 10px 20px / 18px 10px 20px 12px; } +.js:not(.has-scene) .key { visibility: hidden; } +.key-t, .key-or { font: 600 16px/1.25 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } +.key .touch { display: none; } +@media (pointer: coarse) { .key .mouse { display: none; } .key .touch { display: inline; } } +.keys { display: flex; flex-wrap: wrap; gap: 8px; } +.note .keys { margin-top: 9px; } +.note .key-or { margin-top: 7px; } +.keys button { + --ox: -3px; --oy: -2px; + position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 4px; height: 36px; padding: 0 12px 0 6px; + border-radius: 999px; background: var(--paper); font: 600 15px/1 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; + transition: background-color .15s, rotate .2s var(--ease); +} +.keys button:hover { background: #fff6c2; rotate: -2deg; } +.keys button[aria-pressed="true"] { background: var(--hi); } +.keys svg { width: 26px; height: 20px; flex: none; } +/* The ball game moves; without motion it isn't offered */ +@media (prefers-reduced-motion: reduce) { .keys [data-play] { display: none; } } + +/* ---------- Sections: each a different piece of paper, in the left part of the window ---------- */ +.sec { display: flex; align-items: center; min-height: 100vh; min-height: 100svh; padding: 14vh 0 14vh var(--left, 5vw); } +.sec .note { width: min(44vw, 620px); } +.edit { --left: 7vw; } +.themes { --left: 4.5vw; } +.widgets { --left: 6vw; } +.tiling { --left: 7.5vw; } +.install { --left: 5vw; } +/* Narrow landscape screens: wider notes so the Download button stays on its paper, still clear of the lighthouse at 0.775 × width */ +@media (min-width: 700px) and (max-width: 1023.98px) { + .hero-note, .sec .note { width: min(58vw, 620px); } + .hero-note { padding-left: 30px; padding-right: 30px; } + .key { max-width: min(562px, 58vw); } +} + +/* Edit: a taped note with a picture of the bar in edit mode */ +.taped { --rot: .7deg; --tape-x: 9%; --tape-r: -32deg; --tape-w: 104px; } +.taped::after { top: -6px; } +.taped .tape2 + h2 { margin-top: 0; } +.taped .tape2 { position: absolute; top: -4px; right: -12px; width: 96px; height: 28px; rotate: 34deg; background: var(--tape); clip-path: polygon(0 6%, 5% 0, 10% 8%, 50% 3%, 90% 6%, 95% 0, 100% 8%, 99% 92%, 94% 100%, 50% 96%, 6% 100%, 1% 92%); pointer-events: none; z-index: 1; } +.screen { position: relative; border-radius: 13px; background: var(--screen); box-shadow: 0 0 0 2.2px var(--pen); } +.fig-edit { display: block; margin-top: 22px !important; padding: 40px 12px 16px; color: var(--fg); font: var(--bar-font); -webkit-font-smoothing: auto; container-type: inline-size; } +.fig-edit .trays { display: flex; flex-wrap: wrap; gap: 8px; } +.fig-edit .tray { position: relative; display: flex; align-items: center; gap: 6px; min-height: 40px; padding: 6px 7px; border-radius: 9px; background: linear-gradient(var(--tray-tint) 0 0) var(--tray-backing); box-shadow: inset 0 0 0 .5px var(--tray-line); } +.fig-edit .tray.center { flex: 1; justify-content: center; min-width: 48px; } +.fig-edit .tile { cursor: default; } +.fig-edit .w { cursor: default; } +.fig-edit .w.ws { gap: 2px; padding: 0 4px; } +.fig-edit .w.ws .wsi { height: 22px; cursor: default; } +.fig-edit .w.ws .wsi.on { background: var(--ws-ind); } +.fig-edit .arrow { position: absolute; left: var(--ax, 196px); top: 6px; width: var(--aw, 150px); height: 36px; color: var(--pen); pointer-events: none; } +[data-mode="dark"] .fig-edit .arrow { color: #fff; } +.fig-edit .drag-note { position: absolute; left: calc(var(--ax, 196px) + var(--aw, 150px) * .42); top: 2px; font: 600 15px/1 var(--hand); color: var(--pen); rotate: -4deg; } +[data-mode="dark"] .fig-edit .drag-note { color: #fff; } +@container (max-width: 470px) { .fig-edit .arrow, .fig-edit .drag-note { display: none; } .fig-edit { padding-top: 16px; } } +.try { + position: relative; display: inline-flex; align-items: center; gap: 8px; margin-top: 20px !important; padding: 9px 16px 10px; + border-radius: 10px 14px 12px 9px; background: var(--paper); box-shadow: 0 0 0 2.2px var(--pen), 3px 3px 0 2.2px var(--pen); + font: 600 17px/1.2 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; transition: translate .15s, box-shadow .15s; +} +.try::after { content: "↑"; font-family: var(--body); font-weight: 700; } +.try:hover { background: #fff6c2; } +.try:active { translate: 2px 2px; box-shadow: 0 0 0 2.2px var(--pen), 1px 1px 0 2.2px var(--pen); } + +/* Themes: seven tags pegged on a washing line, each a miniature bar in its theme */ +.line-note { --rot: -.9deg; } +.line-note::after { display: none; } +.line { + display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); grid-auto-rows: 112px; column-gap: 12px; + margin: 18px -12px 0 !important; padding: 0 12px; background: var(--string) 0 0 / 100% 112px repeat-y; +} +.tag { + --ox: -3px; --oy: -2px; --r: -2deg; + position: relative; isolation: isolate; align-self: start; display: flex; flex-direction: column; gap: 7px; + margin-top: 18px; padding: 8px 8px 9px; border-radius: 6px 9px 7px 10px; background: var(--paper); text-align: left; + rotate: var(--r); transform-origin: 50% -8px; transition: rotate .35s var(--ease), background-color .15s; +} +.tag:nth-child(3n + 2) { --r: 1.8deg; } +.tag:nth-child(3n) { --r: -.5deg; } +.tag:hover { --r: 0deg; } +.tag[aria-pressed="true"] { background: var(--hi); } +/* a wooden peg */ +.tag::after { + content: ""; position: absolute; top: -19px; left: 50%; width: 10px; height: 24px; translate: -50% 0; rotate: 4deg; + border-radius: 3px 3px 4px 4px; background: linear-gradient(90deg, #d79b5f 0 45%, #b8783f 45% 55%, #d79b5f 55%); box-shadow: 0 0 0 1.8px var(--pen); +} +.tag .shot { position: relative; height: 40px; overflow: hidden; border-radius: 5px; background: var(--screen); box-shadow: 0 0 0 1.6px var(--pen); } +.tag .nm { display: flex; align-items: center; justify-content: space-between; gap: 6px; font: 600 16px/1 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } +.tag[aria-pressed="true"] .nm::after { content: "✓"; font-family: var(--body); font-weight: 700; } + +/* Small sample bars in the theme tags: three workspaces, Finder, battery and clock, as in Settings' theme cards */ .mini { - display: flex; align-items: center; justify-content: space-between; gap: 6px; height: 100%; - font: var(--bar-font); color: var(--fg); -webkit-font-smoothing: auto; padding: 0 calc(5px + var(--strip-m) * .5); + position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; gap: 6px; + width: 200%; height: 200%; padding: 0 calc(5px + var(--strip-m) * .5); transform: scale(.5); transform-origin: 0 0; + font: var(--bar-font); color: var(--fg); -webkit-font-smoothing: auto; } -.mini .strip { position: absolute; inset: 3px calc(3px + var(--strip-m) * .4); background: var(--strip-bg); border-radius: var(--strip-radius); box-shadow: var(--strip-edge); } +.mini .strip { position: absolute; inset: 6px calc(6px + var(--strip-m) * .4); background: var(--strip-bg); border-radius: var(--strip-radius); box-shadow: var(--strip-edge); } .mini .g { display: flex; gap: 4px; position: relative; } -.mini .t { position: relative; filter: var(--w-glow); background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); padding: 0 7px; height: calc(var(--tile-h) * 22 / 28); display: flex; align-items: center; gap: 5px; white-space: nowrap; } -.mini .wsi { padding: 0 6px; border-radius: calc(var(--ws-r) * 22 / 28); cursor: default; } +.mini .t { position: relative; filter: var(--w-glow); background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); padding: 0 7px; height: var(--tile-h); display: flex; align-items: center; gap: 5px; white-space: nowrap; } +.mini .wsi { padding: 0 6px; cursor: inherit; } .ws-under .mini .wsi.on::after { bottom: 1px; } -.card .mini { transform: scale(.52); transform-origin: left center; width: 192%; } - -/* ---------- Sections ---------- */ -.stop { position: relative; padding: 72px 0 20px; } -.stop.first { padding-top: 96px; } -.stop h2 { margin-bottom: 26px; font: 600 clamp(30px, 7.6vw, 48px)/1.04 var(--display); letter-spacing: -.03em; } -.body p { max-width: 52ch; color: var(--text-2); } -.body p + p { margin-top: 12px; } -.body p strong { color: var(--text); font-weight: 500; } -.more-link { display: inline-block; margin-top: 16px; font-size: 14.5px; color: var(--text); border-bottom: 1px solid var(--line-2); padding-bottom: 2px; } -.more-link:hover { border-color: var(--text); } -.more-link.try { margin: 14px 0 6px; } -.try + p { margin-top: 16px; } -.fig { margin-top: 40px; position: relative; } - -/* Configure all of it: two groups of specs */ -.specs h3 { margin-bottom: 4px; font: 500 13.5px/1.3 var(--body); color: var(--text-3); } -.specs h3 + ul { margin-bottom: 32px; } -.specs ul { list-style: none; border-bottom: 1px solid var(--line); } -.specs li { padding: 11px 0; border-top: 1px solid var(--line); } -.specs b { display: block; font: 600 15px/1.3 var(--display); } -.specs span { font-size: 13.5px; line-height: 1.4; color: var(--text-3); } - -/* Popups */ -.pops { position: relative; display: grid; gap: 14px; } -.pops .pop { position: relative; opacity: 1; visibility: visible; transform: none; width: auto; max-width: 320px; } - -/* Themes: every included theme as a card that restyles the bar */ -.board { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.board .card { padding: 6px 6px 9px; background: none; box-shadow: inset 0 0 0 1px var(--line); } -.board .card:hover { box-shadow: inset 0 0 0 1px var(--line-2); } -.board .card.on { box-shadow: inset 0 0 0 2px var(--ring); } -.board .card .nm { margin: 8px 3px 0; font: 600 14px/1.3 var(--display); } -.board .card .nm svg { stroke: var(--ring); } -.board .store { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 14px; color: var(--text-2); font-size: 14px; } -.board .store:hover { color: var(--text); } -.board .store svg { width: 14px; height: 14px; } - -/* Scripts */ -.code { border-radius: 12px; background: var(--code-bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; } -.code .fn { display: flex; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--line); font: 500 12px/1.2 var(--mono); color: var(--text-3); } -.code pre { padding: 12px 14px 14px; font: 400 12.5px/1.6 var(--mono); color: var(--code-fg); overflow-x: auto; } -.code pre:focus-visible { outline-offset: -2px; border-radius: 0 0 12px 12px; } -.code .c { color: var(--code-dim); } -.code + .code { margin-top: 12px; } -.result { position: relative; margin: -18px 0 0 auto; width: max-content; z-index: 2; display: flex; gap: 6px; padding: 6px; border-radius: 12px; background: var(--dim), var(--backdrop) center / cover; box-shadow: inset 0 0 0 1px var(--line), 0 20px 50px -20px var(--shadow-1); } + +/* Scripts and the Store: a page torn from a notebook, ruled, with a red margin */ +.notebook { --rot: .5deg; --ox: -5px; --oy: -3px; background: none; padding: 46px 34px 34px 70px; line-height: 28px; } +.notebook::after { + content: ""; inset: 0; top: 0; left: 0; width: auto; height: auto; translate: none; rotate: none; z-index: -2; + border-radius: 0 0 14px 22px / 0 0 22px 14px; clip-path: var(--torn, none); + background: + linear-gradient(90deg, transparent 48px, #e8786b 48px 50px, transparent 50px), + repeating-linear-gradient(transparent 0 27px, #b7d2ef 27px 28px) 0 17px / 100% 28px, + var(--paper); +} +.notebook > * + * { margin-top: 14px; } +.notebook h2 { line-height: 1.1; } +.notebook .script { margin-top: 18px; } +.code { border-radius: 10px; background: #fbfaf6; box-shadow: 0 0 0 2px var(--pen); overflow: hidden; } +.code .fn { display: flex; justify-content: space-between; padding: 7px 14px; border-bottom: 2px solid var(--pen); font: 500 12.5px/1.2 var(--mono); color: #4a4a4a; background: #fff2a8; } +.code pre { padding: 12px 14px 14px; font: 400 13px/1.6 var(--mono); color: #1d1d1f; overflow-x: auto; } +.code pre:focus-visible { outline-offset: -3px; border-radius: 0 0 10px 10px; } +.code .c { color: #6b6b6b; } +.result { display: flex; gap: 6px; width: max-content; max-width: 100%; margin: -14px 18px 0 auto; padding: 7px; } .result .w { font: var(--bar-font); -webkit-font-smoothing: auto; color: var(--fg); } -.result .w.new { box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 2px var(--amber); } +.result .w.new { box-shadow: inset 0 0 0 1px var(--w-border), 0 0 0 2.5px var(--amber); } +.store { margin-top: 30px !important; } +.store > * + * { margin-top: 12px; } +.store-fig { position: relative; margin-top: 18px !important; rotate: -.6deg; } /* The Store: Settings → Packages, as the app draws it */ -.body code { font: 400 .92em/1 var(--mono); color: var(--text); } -.store-win .tb { gap: 16px; padding: 12px 14px; border-bottom: 1px solid color-mix(in srgb, var(--win-fg) 9%, transparent); } -.tabs, .kinds { display: flex; padding: 2px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 8%, transparent); font-size: 12.5px; } -.tabs span, .kinds span { padding: 4px 10px; border-radius: 5px; color: color-mix(in srgb, var(--win-fg) 80%, transparent); } -.tabs .on, .kinds .on { background: color-mix(in srgb, var(--win-fg) 16%, transparent); color: var(--win-fg); } -.store-win .find { flex: 1; min-width: 0; max-width: 220px; padding: 5px 10px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 6%, transparent); color: color-mix(in srgb, var(--win-fg) 50%, transparent); font-size: 12.5px; white-space: nowrap; overflow: hidden; } -.kinds { width: max-content; margin: 14px 14px 0; } -.pkgs { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; padding: 14px; } -.pkg { display: flex; flex-direction: column; min-width: 0; padding: 10px; border-radius: 10px; background: color-mix(in srgb, var(--win-fg) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--win-fg) 8%, transparent); } -.pkg .pv { display: grid; place-items: center; height: 58px; border-radius: 7px; background: var(--dim), var(--backdrop) center / cover; font: var(--bar-font); -webkit-font-smoothing: auto; color: var(--fg); overflow: hidden; } +.win { border-radius: 12px; background: var(--win-bg); color: var(--win-fg); font: 400 13px/1.3 var(--mac); box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 0 0 2.4px var(--pen), 6px 8px 0 0 #1111111f; } +[data-mode="dark"] .store-fig { color-scheme: dark; } +.win .tb { position: relative; display: flex; align-items: center; gap: 14px; padding: 11px 14px; } +.tl { display: flex; gap: 8px; } +.tl i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; } +.tl i:nth-child(2) { background: #febc2e; } .tl i:nth-child(3) { background: #28c840; } +.store-win .tb { gap: 14px; border-bottom: 1px solid color-mix(in srgb, var(--win-fg) 9%, transparent); } +.tabs { display: flex; padding: 2px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 8%, transparent); font-size: 12.5px; } +.tabs span { padding: 4px 10px; border-radius: 5px; color: color-mix(in srgb, var(--win-fg) 80%, transparent); } +.tabs .on { background: color-mix(in srgb, var(--win-fg) 16%, transparent); color: var(--win-fg); } +.store-win .find { flex: 1; min-width: 0; max-width: 200px; padding: 5px 10px; border-radius: 7px; background: color-mix(in srgb, var(--win-fg) 6%, transparent); color: color-mix(in srgb, var(--win-fg) 50%, transparent); font-size: 12.5px; white-space: nowrap; overflow: hidden; } +.pkgs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 9px; padding: 12px; line-height: 1.3; } +.pkg { display: flex; flex-direction: column; min-width: 0; padding: 9px; border-radius: 10px; background: color-mix(in srgb, var(--win-fg) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--win-fg) 8%, transparent); } +.pkg .pv { display: grid; place-items: center; height: 50px; border-radius: 7px; background: var(--screen); font: var(--bar-font); -webkit-font-smoothing: auto; color: var(--fg); overflow: hidden; } .pkg .pv .w { height: 26px; } -.pkg b { margin-top: 10px; font-weight: 600; font-size: 13.5px; } -.pkg p { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 3px; font-size: 12px; line-height: 1.35; color: color-mix(in srgb, var(--win-fg) 62%, transparent); } -.pkg .by { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 10px; font-size: 11.5px; color: color-mix(in srgb, var(--win-fg) 55%, transparent); } +.pkg b { margin-top: 9px; font-weight: 600; font-size: 13px; } +.pkg p { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 3px; font-size: 11.5px; line-height: 1.35; color: color-mix(in srgb, var(--win-fg) 66%, transparent); } +.pkg .by { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: auto; padding-top: 9px; font-size: 11px; color: color-mix(in srgb, var(--win-fg) 60%, transparent); } .pkg .by > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pkg .pkg-get { height: 24px; padding: 0 12px; border-radius: 6px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); color: var(--win-fg); font-size: 12.5px; } +.pkg .pkg-get { flex: none; height: 24px; padding: 0 12px; border-radius: 6px; background: color-mix(in srgb, var(--win-fg) 12%, transparent); color: var(--win-fg); font-size: 12.5px; } .pkg .pkg-get:hover { background: color-mix(in srgb, var(--win-fg) 18%, transparent); } .pkg .state { display: flex; align-items: center; gap: 5px; white-space: nowrap; } .pkg .state svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 1.8; } .store-win { position: relative; } .store-win::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #00000059; opacity: 0; visibility: hidden; transition: opacity .2s, visibility 0s .2s; } .store-win.sheet::after { opacity: 1; visibility: visible; transition: opacity .2s; } -.pkg-review { z-index: 3; top: 56px; left: 50%; translate: -50% 0; width: min(400px, calc(100% - 24px)); } +.addpop.pkg-review { z-index: 3; top: 52px; left: 50%; translate: -50% 0; width: min(400px, calc(100% - 24px)); line-height: 1.3; } .pkg-review::before { content: none; } -/* Displays: two screens, each with its own bar */ -.setup { display: grid; gap: 16px; margin: 40px calc(var(--gutter) * -1) 0; } -.cap { - position: relative; overflow: hidden; height: 300px; border-radius: 12px; - background: linear-gradient(transparent 45%, #000000a6), var(--dim), var(--backdrop) 50% 40% / cover; - box-shadow: 0 0 0 .5px var(--win-ring), inset 0 0 0 1px var(--win-edge), 0 40px 90px -30px var(--shadow-1); -} -.cap .bar { transition: none; } -.cap .w { cursor: default; } -.cap .pop { right: 150px; top: calc(var(--bar-h) - var(--strip-m) + 6px); width: 250px; } -.cap figcaption { position: absolute; left: 16px; right: 16px; bottom: 14px; font-size: 13.5px; line-height: 1.4; color: #fff; } -.cap figcaption b { font-weight: 600; font-family: var(--display); } -.cap.monitor figcaption { text-align: right; } -.cap.monitor .gl { display: none; } -.cap.monitor .bar { justify-content: flex-end; } -.cap.laptop { height: 220px; border-radius: 14px 14px 6px 6px; background-image: linear-gradient(transparent 45%, #000000a6), var(--dim), var(--backdrop-2); } -.cap.laptop .bar { padding: 0 10px; } -.notch { position: absolute; top: 0; left: 50%; width: 96px; height: 34px; translate: -50% 0; background: #000; border-radius: 0 0 11px 11px; z-index: 1; } - -/* ---------- Install ---------- */ -.install { position: relative; margin-top: 40px; padding: 88px 0 96px; isolation: isolate; } -.install h2 { font: 600 clamp(40px, 11vw, 88px)/.98 var(--display); letter-spacing: -.04em; max-width: 11ch; } -.install .lede { max-width: 40ch; } -.install .cta { height: 54px; padding: 0 24px 0 20px; font-size: 17px; border-radius: 11px; } -.install .cta svg { width: 17px; height: 20px; } - -/* The disk image window, on setup's backdrop: drag StableBar onto Applications */ -.desktop { position: relative; isolation: isolate; display: grid; place-items: center; margin: 48px calc(var(--gutter) * -1) 0; padding: 40px 20px; } -.desktop .wall { -webkit-mask-image: linear-gradient(#0009 0 0); mask-image: linear-gradient(#0009 0 0); } -.dmg { width: 100%; max-width: 520px; } -.dmg .stage { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 24px 18px 18px; } -.slot { justify-self: center; display: grid; justify-items: center; gap: 8px; font-size: 12.5px; } -.slot .ico { width: 84px; height: 84px; display: grid; place-items: center; } -.app-ico { width: 84px; height: 84px; touch-action: none; cursor: grab; user-select: none; -webkit-user-drag: none; position: relative; z-index: 2; transition: transform .45s var(--ease), opacity .3s; } -.app-ico.dragging { cursor: grabbing; transition: none; } -.app-ico.in { opacity: 0; } -.dmg .arrow { display: grid; justify-items: center; gap: 8px; color: var(--text-3); font: 400 12px/1 var(--mono); } -.dmg .arrow svg { width: 72px; height: 14px; stroke: var(--amber); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } -.folder { width: 80px; height: 64px; transition: transform .25s var(--ease); } -.folder.over { transform: scale(1.08); } -.dmg .foot { padding: 0 18px 16px; text-align: center; color: var(--text-3); font-size: 12.5px; } - -footer { padding: 22px 0 40px; border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 13.5px; color: var(--text-3); } -footer span { flex-basis: 100%; } -footer a:hover { color: var(--text); } - -/* ---------- Privacy Policy and Terms ---------- */ -.legal { max-width: 68ch; padding: 36px 0 64px; } -.legal h1 { font-size: clamp(34px, 8vw, 48px); max-width: none; } -.legal .updated { margin-top: 10px; font-size: 14px; color: var(--text-3); } -.legal h2 { margin-top: 36px; font: 600 22px/1.2 var(--display); letter-spacing: -.01em; } -.legal p, .legal ul { margin-top: 12px; color: var(--text-2); } -.legal ul { padding-left: 1.2em; } -.legal li + li { margin-top: 6px; } -.legal strong { font-weight: 600; color: var(--text); } -.legal a { text-decoration: underline; text-underline-offset: 3px; } -.legal a:hover { color: var(--text); } -.legal code { font: 14px var(--mono); } - -/* Arrival, then sections easing in as they scroll into view */ -@media (prefers-reduced-motion: no-preference) { - .js .hero h1, .js .hero .lede, .js .hero .get { opacity: 0; translate: 0 12px; } - .js .wall { opacity: 0; } - .js #bar .g > .w:not(.dl) { opacity: 0; translate: 0 -6px; } - .js .layer { opacity: 0; translate: 0 28px; } - .arrived .hero h1, .arrived .hero .lede, .arrived .hero .get { opacity: 1; translate: none; transition: opacity .8s var(--ease) var(--d, 0s), translate 1s var(--ease) var(--d, 0s); } - .arrived .hero .lede { --d: .08s; } - .arrived .hero .get { --d: .16s; } - .arrived .wall { opacity: 1; transition: opacity 1.4s var(--ease) .2s; } - .arrived #bar .g > .w:not(.dl) { opacity: 1; translate: none; transition: background .15s, border-radius .25s, opacity .5s var(--ease) calc(.4s + var(--i) * 35ms), translate .7s var(--ease) calc(.4s + var(--i) * 35ms); } - .arrived .layer { opacity: 1; translate: none; transition: opacity .8s var(--ease) var(--d), translate 1.1s var(--ease) var(--d); } - .arrived .theme-layer { --d: .75s; } - .arrived .term-layer { --d: .9s; } - .js .edit-ctl { opacity: 0; } - .arrived .edit-ctl { opacity: 1; transition: opacity .6s var(--ease) 1s; } - - .js .reveal { opacity: 0; translate: 0 8px; transition: opacity .6s var(--ease), translate .8s var(--ease); } - .js .reveal.in { opacity: 1; translate: none; } -} - -/* Phones: keep the header bar to the essentials */ -@media (max-width: 719px) { - .pops .pop { width: calc(100% - 48px); } - .pops .p2 { margin-left: 24px; } - .pops .p3 { margin-left: 48px; } +/* Tiling: a postcard, with the workspace widget large as its picture */ +.postcard { + --rot: -1.1deg; --tape-x: 88%; --tape-r: 28deg; --tape-w: 90px; + display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, 34%); column-gap: 26px; + padding: 30px 30px 24px; border-radius: 8px 12px 9px 14px / 12px 8px 14px 9px; +} +.postcard > * + * { margin-top: 0; } +.pc-msg > * + * { margin-top: 12px; } +.pc-addr { position: relative; display: flex; flex-direction: column; gap: 10px; padding-left: 22px; } +.pc-addr::before { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 2.2px; border-radius: 2px; background: var(--pen); rotate: .8deg; } +.stamp { + align-self: flex-end; display: grid; place-items: center; width: 62px; height: 74px; margin-bottom: 12px; rotate: 3deg; + background: #9fd6f5; + -webkit-mask: radial-gradient(circle at 50% 50%, #000 2.6px, transparent 2.8px) -4px -4px / 8px 8px, linear-gradient(#000 0 0) center / calc(100% - 8px) calc(100% - 8px) no-repeat; + mask: radial-gradient(circle at 50% 50%, #000 2.6px, transparent 2.8px) -4px -4px / 8px 8px, linear-gradient(#000 0 0) center / calc(100% - 8px) calc(100% - 8px) no-repeat; +} +.stamp svg { width: 40px; height: 48px; } +.postmark { position: absolute; top: -6px; right: 34px; width: 74px; height: 50px; rotate: -12deg; color: #3a3a46; opacity: .75; pointer-events: none; } +.pc-addr .to { font: 600 15px/1 var(--hand); color: var(--red); } +.pc-addr .addr { padding: 0 2px 4px; border-bottom: 2px dotted #9aa3b5; font: 600 19px/1.1 var(--hand); font-variation-settings: "INFM" 100, "BNCE" 60; } +.pc-ws { grid-column: 1 / -1; display: flex; justify-content: center; margin-top: 22px; padding: 22px 12px; } +.ws-big { + display: flex; align-items: center; gap: 3px; height: 34px; padding: 0 5px; zoom: 1.7; + background: var(--w-bg); box-shadow: inset 0 0 0 1px var(--w-border), 0 1px 3px var(--w-shadow); border-radius: var(--w-radius); + color: var(--fg); font: var(--bar-font); -webkit-font-smoothing: auto; +} +.ws-big .wsi { height: 26px; } +.pc-foot { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; margin-top: 18px !important; } +.note .pc-foot .cap { margin-top: 0; } + +/* Install: a recipe card, numbered */ +.recipe { + --rot: .9deg; --tape-x: 50%; --tape-r: 2deg; + padding: 30px 34px 30px; border-radius: 6px 10px 8px 12px / 10px 6px 12px 8px; +} +.recipe h2 { padding-bottom: 12px; border-bottom: 2.4px solid #e8786b; } +.steps { list-style: none; counter-reset: step; background: repeating-linear-gradient(transparent 0 33px, #c3d8f0 33px 35px) 0 0 / 100% 35px; } +.note .steps { margin-top: 18px; } +.steps li { position: relative; counter-increment: step; padding-left: 44px; line-height: 35px; } +.steps li::before { + content: counter(step); position: absolute; left: 0; top: 2px; width: 30px; height: 30px; display: grid; place-items: center; + border-radius: 54% 46% 50% 48% / 48% 54% 46% 52%; box-shadow: 0 0 0 2.2px var(--pen); background: var(--paper); + font: 600 16px/1 var(--hand); color: var(--red); +} +.steps li:nth-child(2n)::before { rotate: -8deg; } +.brew { font-size: 15.5px; color: var(--text-2); } +.brew code { display: inline-block; margin-top: 4px; padding: 3px 8px; border-radius: 6px; background: #fbfaf6; box-shadow: 0 0 0 1.6px var(--pen); font: 400 14px/1.4 var(--mono); color: #1d1d1f; white-space: nowrap; } + +/* ---------- Portrait: phones and portrait tablets. The scene's rule: narrower than 700 px, or taller than 1.1 × wide ---------- */ +@media (max-width: 699.98px), (max-aspect-ratio: 10/11) { + .hero, .sec { --left: var(--m); padding-left: var(--m); padding-right: var(--m); align-items: stretch; } + :root { --m: clamp(14px, 4vw, 32px); } + .hero { padding-top: calc(var(--bar-h) + 24vh); padding-top: calc(var(--bar-h) + 24lvh); padding-bottom: 0; min-height: 0; } + .hero-note, .sec .note { width: auto; max-width: 680px; } + .key { width: auto; max-width: 680px; margin-top: 28px; } + .sec { display: block; min-height: 0; padding-top: 34vh; padding-bottom: 6vh; } + .install { padding-bottom: 12vh; } + h1 { font-size: clamp(34px, 9.4vw, 58px); } +} +@media (max-width: 719.98px) { + body { font-size: 17px; } + .note { padding: 26px 22px 24px; } + .hero-note { padding: 28px 22px 24px; } + .hero-note .lede { margin-top: 14px; } + .note .get { margin-top: 20px; } + .cta { min-height: 50px; padding: 10px 22px 10px 18px; font-size: 18px; } + h2 { font-size: 30px; } + .notebook { padding: 40px 18px 26px 52px; } + .notebook::after { background: linear-gradient(90deg, transparent 36px, #e8786b 36px 38px, transparent 38px), repeating-linear-gradient(transparent 0 27px, #b7d2ef 27px 28px) 0 17px / 100% 28px, var(--paper); } + .result { margin-right: 8px; } + .postcard { grid-template-columns: 1fr; } + .pc-addr { flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin-top: 18px !important; padding: 16px 70px 0 0; } + .pc-addr::before { left: 0; right: 0; top: 0; bottom: auto; width: auto; height: 2.2px; } + .stamp { position: absolute; right: 0; top: 14px; width: 52px; height: 62px; margin: 0; } + .stamp svg { width: 32px; height: 40px; } + .postmark { top: 12px; right: 30px; width: 60px; height: 42px; } + .ws-big { zoom: 1.25; } + .ws-big .ai { display: none; } .code pre { font-size: 11.5px; } + .brew code { white-space: normal; } + .taped .tape2 { right: 4px; } + .try, .edit-ctl { display: none !important; } + .pkg:nth-child(n + 5) { display: none; } + .store-win .find { display: none; } + .line { grid-template-columns: repeat(2, minmax(0, 1fr)); } #bar .ws .ai, #bar [data-key="app"], #bar [data-key="meeting"], #bar [data-key="system"], #bar [data-key="sound"], #bar [data-key="battery"], #bar .np .track, #bar .clock .day, - .header [data-key="clock"], .site .menus, .cap.laptop .app, .cap .meeting { display: none; } - .cap .pop { right: 12px; } - .edit-ctl, .try { display: none !important; } - .store-win .find, .pkg:nth-child(n+5) { display: none; } - .try + p { margin-top: 12px; } - .theme-layer { width: min(296px, calc(100% - 32px)); } - .theme-win .tb { gap: 10px; padding: 11px 12px 8px; } - .theme-win .tl { gap: 6px; } - .theme-win .tl i { width: 10px; height: 10px; } - .cards { gap: 6px; max-height: 100px; padding: 2px 10px 10px; } - .cards .card { padding: 4px 4px 5px; border-radius: 7px; } - .cards .shot { height: 30px; } - .cards .mini { transform: scale(.42); width: 238%; } - .cards .nm { margin-top: 4px; font-size: 11px; } -} - -/* ---------- Wider screens ---------- */ -@media (min-width: 720px) { - :root { --gutter: 32px; } - .nav .gh { display: inline; } - .desk { height: 640px; margin-left: 0; margin-right: 0; --desk-r: 16px; } - .desktop { margin-left: 0; margin-right: 0; padding: 64px 40px; --desk-r: 16px; } - .term-layer { display: block; right: 3%; top: 120px; width: 390px; } - .theme-layer { left: 3%; width: 470px; bottom: 24px; } - .cards { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .pops { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } - .setup { margin-left: 0; margin-right: 0; } - .cap.laptop { height: 260px; } - .notch { width: 132px; } - .install { padding-top: 120px; padding-bottom: 128px; } + .header [data-key="clock"], .site .menus { display: none; } +} +@media (max-width: 380px) { + .keys button { padding-right: 11px; font-size: 14.5px; } + .cta { font-size: 17px; padding-left: 16px; padding-right: 18px; } } @media (min-width: 1080px) { - :root { --gutter: 48px; } - h1 .wide { display: inline; } - h1 { max-width: none; font-size: clamp(62px, 5.6vw, 84px); } .header .w.ws { display: flex; } - .hero { padding: 64px 0 0; } - .lede { font-size: 19px; } - .hero .lede { max-width: none; } - .desk { height: 680px; margin-top: 56px; } - .theme-layer { left: 5%; width: 520px; bottom: 40px; } - .term-layer { right: 4%; top: 300px; width: 410px; } - .stop { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 28px; padding-top: 160px; } - .stop.first { padding-top: 120px; } - .stop h2 { grid-column: 1 / -1; margin-bottom: 36px; } - .body { grid-column: 1 / 5; } - .fig { grid-column: 6 / -1; margin-top: 0; } - .specs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; } - .specs h3 + ul { margin-bottom: 0; } - .pops { display: block; max-width: 715px; height: 470px; margin-left: auto; } - .pops .pop { position: absolute; width: 290px; } - .pops .p1 { left: 0; top: 0; z-index: 0; box-shadow: inset 0 0 0 1px var(--pop-border), 0 10px 30px -18px var(--shadow-2); } - .pops .p2 { left: min(26%, 190px); top: 150px; z-index: 1; } - .pops .p3 { left: min(58%, 425px); top: 40px; z-index: 2; box-shadow: inset 0 0 0 1px var(--pop-border), 0 40px 90px -30px var(--shadow-1), 0 16px 40px -20px var(--shadow-2); } - .flip .body { grid-column: 9 / -1; grid-row: 2; } - .flip .fig { grid-column: 1 / 8; grid-row: 2; } - .wide .fig { grid-column: 1 / -1; margin-top: 56px; } - .board { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } - .board .card .shot { height: 50px; } - .board .card .mini { transform: scale(.75); width: 133.33%; } - .scripts .code:first-child { margin-right: 16%; } - .scripts .code + .code { position: relative; margin: -12px 0 0 16%; } - .result { margin: -22px 8% 0 auto; } - .setup { grid-column: 1 / -1; justify-self: end; display: block; position: relative; width: min(100%, 1280px); height: 600px; margin-top: 64px; } - .cap { position: absolute; } - .cap.monitor { right: 0; top: 0; width: min(940px, 72%); height: 500px; } - .cap.laptop { left: 0; bottom: 0; width: min(620px, 46%); height: 340px; z-index: 2; } - .cap.monitor .gl { display: flex; } - .cap.monitor .bar { justify-content: space-between; } - .install { padding: 160px 0 150px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 28px; align-items: end; } - .install .copy { grid-column: 1 / 7; } - .desktop { grid-column: 7 / -1; margin-top: 0; } } + +/* ---------- Privacy Policy and Terms: one sheet of paper on the sky, no drawing ---------- */ +.doc .page { + position: relative; isolation: isolate; max-width: 820px; margin: 40px auto 56px; padding: 6px 48px 8px; + background: var(--paper); color: var(--text); border-radius: 14px 22px 12px 26px / 22px 12px 26px 14px; +} +.doc .page::before { content: ""; position: absolute; inset: -4px 6px 4px -6px; z-index: -1; border: 2.6px solid var(--pen); border-radius: inherit; pointer-events: none; } +.doc .page::after { content: ""; position: absolute; top: -15px; left: 50%; width: 130px; height: 30px; translate: -50% 0; rotate: -2deg; background: var(--tape); } +.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 76px; padding: 10px 0; background: var(--rule) left bottom / 160px 10px repeat-x; } +.brand { display: flex; align-items: center; gap: 10px; font: 800 22px/1 var(--display); text-decoration: none; } +.brand img { width: 30px; height: 30px; } +.nav { display: flex; flex-wrap: wrap; gap: 4px 20px; font: 600 17px/1 var(--hand); } +.nav a { padding: 8px 0; color: var(--link); text-decoration: none; } +.nav a:hover { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 4px; } +.legal { padding: 34px 0 40px; } +.legal h1 { rotate: none; font-size: clamp(36px, 7vw, 54px); line-height: 1.05; } +.legal .updated { margin-top: 10px; font: 600 16px/1.3 var(--hand); color: var(--red); } +.legal h2 { margin-top: 38px; font-size: 27px; line-height: 1.15; } +.legal p, .legal ul { margin-top: 12px; } +.legal ul { padding-left: 1.25em; } +.legal li + li { margin-top: 8px; } +.legal strong { font-weight: 700; } +.legal a { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } +.legal code { font: 15px var(--mono); } +.doc footer { display: flex; flex-wrap: wrap; gap: 6px 22px; padding: 22px 0 28px; background: var(--rule) left top / 160px 10px repeat-x; font-size: 15.5px; } +.doc footer a { color: var(--link); text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); } +@media (max-width: 719.98px) { + .doc .page { margin: 26px 12px 36px; padding: 4px 20px 6px; } + .doc { font-size: 17px; } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; transition-delay: 0s !important; } } diff --git a/site/styles/cartoon.js b/site/styles/cartoon.js new file mode 100644 index 00000000..1cbb717d --- /dev/null +++ b/site/styles/cartoon.js @@ -0,0 +1,233 @@ +/* Lighthouse Hill in a magazine cartoon, for Notch (see the styles in scene.js): a brush pen held still, no colour, + grey washes on white paper that pool at their edges and sit off the line, and a caption in Caslon italic under the + drawing, one per section */ +(() => { +const { clamp, lerp, smooth, easeOut, noise, hash, rng, mix, rgba, poly, dens, SIL, TEX } = Hill; +const PAPER = [251, 248, 241], INK = [24, 22, 20], paper = `rgb(${PAPER})`, ink = `rgb(${INK})`; +const lum = c => (c[0] * 0.299 + c[1] * 0.587 + c[2] * 0.114) / 255; +const CASLON = '"Libre Caslon Text", Georgia, serif'; +document.fonts.add(new FontFace('Libre Caslon Text', 'url(fonts/libre-caslon-text-italic.woff2)', { style: 'italic', weight: '400' })); + +/* The wash: ink thinned with water, laid on unevenly, a little more of it here and less there. One tile, made once: + the ink's colour, more or less opaque in soft blots. The sea's is the same, drawn out sideways in streaks. */ +const tile = document.createElement('canvas'); +tile.width = tile.height = 512; +{ + const x = tile.getContext('2d'), R = rng(5); + x.fillStyle = `rgba(${INK},0.72)`; x.fillRect(0, 0, 512, 512); + for (let i = 0; i < 140; i++) { + const px = R() * 512, py = R() * 512, r = 14 + R() ** 2 * 130, a = 0.1 + R() * 0.16; + x.globalCompositeOperation = R() < 0.5 ? 'source-over' : 'destination-out'; + for (const ox of [-512, 0, 512]) for (const oy of [-512, 0, 512]) { + const gr = x.createRadialGradient(px + ox, py + oy, 0, px + ox, py + oy, r); + gr.addColorStop(0, `rgba(${INK},${a})`); gr.addColorStop(1, `rgba(${INK},0)`); + x.fillStyle = gr; x.fillRect(px + ox - r, py + oy - r, r * 2, r * 2); + } + } +} +const WASH = tile.getContext('2d').createPattern(tile, 'repeat'), SEA = tile.getContext('2d').createPattern(tile, 'repeat'); +WASH.setTransform(new DOMMatrix([1.5, 0, 0, 0.8, 0, 0])); SEA.setTransform(new DOMMatrix([3.2, 0, 0, 0.45, 0, 0])); + +/* A shape on the paper: white paper up to the line, so it hides what's behind it; then, unless it's white, a wash + as dark as its colour, off the line, darker where it dried at its edges. The near-blacks are filled in solid. */ +function wash(c, pts, col, pat = WASH) { + const k = lum(col), ga = c.globalAlpha; + poly(c, pts); c.fillStyle = paper; c.fill(); + if (k > 0.92) return; + if (k < 0.2) { c.fillStyle = rgba(col); c.fill(); return; } + c.save(); + poly(c, pts, true, Hill.REGX, Hill.REGY); + c.globalAlpha = ga * Math.min(1, (1 - k) / 0.72); c.fillStyle = pat; c.fill(); + c.globalAlpha = ga * (1 - k) * 0.45; c.strokeStyle = ink; c.lineWidth = 1.5 * Hill.PW; c.stroke(); + c.restore(); +} + +/* The brush pen: thick where the stroke runs across the nib and thin along it, swelling from a fine start to a fine + end, steady. The outlines come out heavier than the felt pen's, the hatching and small marks lighter. */ +const NIB = -0.7; +function brush(c, pts, w, sd = 0, o = {}) { + const n = pts.length; + if (n < 2) return; + const PW = Hill.PW, len = [0]; + for (let i = 1; i < n; i++) len.push(len[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])); + const total = len[n - 1] || 1, tin = Math.min(total * 0.45, (o.tin ?? 9) * PW * 1.6), tout = Math.min(total * 0.5, (o.tout ?? 15) * PW * 1.6); + const w0 = (o.w0 ?? 0.4) * 0.5, w1 = (o.w1 ?? 0.22) * 0.5, hw0 = w * PW * 0.5 * (w >= SIL * 0.8 ? 1.2 : w <= TEX * 1.2 ? 0.6 : 1); + const L = [], R = []; + let sx, sy, ex, ey; + for (let i = 0; i < n; i++) { + const a = pts[i > 0 ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : n - 1]; + const tl = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1, tx = (b[0] - a[0]) / tl, ty = (b[1] - a[1]) / tl; + const s = len[i], off = (o.amp ?? 0.85) * PW * 0.3 * noise(s / 110 + sd * 3.71); + const x = pts[i][0] - ty * off, y = pts[i][1] + tx * off; + const p = lerp(w0, 1, easeOut(clamp(s / tin))) * lerp(w1, 1, easeOut(clamp((total - s) / tout))); + const hw = hw0 * p * (0.4 + 0.8 * Math.abs(ty * Math.cos(NIB) - tx * Math.sin(NIB))); + L.push([x - ty * hw, y + tx * hw]); R.push([x + ty * hw, y - tx * hw]); + if (i === 0) { sx = x - tx * hw; sy = y - ty * hw; } + if (i === n - 1) { ex = x + tx * hw; ey = y + ty * hw; } + } + c.moveTo(sx, sy); + for (const [x, y] of L) c.lineTo(x, y); + c.lineTo(ex, ey); + for (let i = n - 1; i >= 0; i--) c.lineTo(R[i][0], R[i][1]); + c.closePath(); +} + +/* The captions, one a section, said by someone in it */ +const CAPTIONS = { + hero: '“He’s very stable. He hasn’t moved in forty years.”', + edit: '“A little to the left. No, my left.”', + themes: '“He changes every evening. It’s always stripes.”', + widgets: '“It shows how full the disk is. I find it calming.”', + tiling: '“We each have our own workspace now. It’s done wonders for the marriage.”', + install: '“Come in. I’ve only just set it up, so don’t touch anything.”', +}; +/* Where the caption goes: under the hill, on the white (on a phone, under the sky, left of the lighthouse, above + where the notes begin), in lines `lh` apart up from `foot`, at most three */ +function captionAt(g) { + const T = g.lh, size = Math.max(15, 21 * g.u), lh = size * 1.32; + const x0 = g.portrait ? 0 : Math.max(g.note.x1, g.ridge[0][0]), x1 = g.portrait ? T.x - T.wB * 0.55 : g.W; + const foot = g.portrait ? g.note.y0 - 10 : g.H - 26 * g.u; + return { size, lh, x0, x1, foot, top: foot - lh * (g.portrait ? 3 : 2) }; +} +// the washes lifted off there, the paper coming back over a short way above it +function lift(c, g, a) { + const z = captionAt(g), yA = z.top - 50 * g.u, yB = g.portrait ? z.foot + 40 : g.H, at = y => (y - yA) / (yB - yA); + const gr = c.createLinearGradient(0, yA, 0, yB); + gr.addColorStop(0, `rgba(${PAPER},0)`); gr.addColorStop(at(z.top), paper); gr.addColorStop(at(z.foot + 6), paper); gr.addColorStop(1, `rgba(${PAPER},${g.portrait ? 0 : 1})`); + c.globalAlpha = a; c.fillStyle = gr; c.fillRect(0, yA, g.W, yB - yA); c.globalAlpha = 1; +} +Hill.style('notch', { + // white paper by day and at night, the sky washed darker with the hour, the light left as paper + pal: Hill.stops([ + [0, { sky: '#FFFFFF', sky2: '#FFFFFF', sea: '#D2D2D2', sea2: '#111111', foam: '#FFFFFF', hill: '#E4E4E4', hill2: '#C2C2C2', ink: '#000000', paper: '#FFFFFF', beam: '#FFFFFF', cloud: '#FFFFFF', far: '#DDDDDD', glass: '#C8C8C8' }], + [0.2, { sky: '#F2F2F2', sky2: '#FFFFFF', sea: '#CACACA', sea2: '#111111', foam: '#FFFFFF', hill: '#DDDDDD', hill2: '#BCBCBC', ink: '#000000', paper: '#FFFFFF', beam: '#FFFFFF', cloud: '#FFFFFF', far: '#D6D6D6', glass: '#C8C8C8' }], + [0.44, { sky: '#D4D4D4', sky2: '#EAEAEA', sea: '#B4B4B4', sea2: '#111111', foam: '#FFFFFF', hill: '#CDCDCD', hill2: '#ADADAD', ink: '#000000', paper: '#FFFFFF', beam: '#FFFFFF', cloud: '#F4F4F4', far: '#C4C4C4', glass: '#C0C0C0' }], + [0.7, { sky: '#A4A4A4', sky2: '#C2C2C2', sea: '#929292', sea2: '#111111', foam: '#F4F4F4', hill: '#B2B2B2', hill2: '#999999', ink: '#000000', paper: '#FFFFFF', beam: '#FFFFFF', cloud: '#D8D8D8', far: '#A0A0A0', glass: '#FFFFFF' }], + [1, { sky: '#7E7E7E', sky2: '#9C9C9C', sea: '#727272', sea2: '#111111', foam: '#E8E8E8', hill: '#A0A0A0', hill2: '#878787', ink: '#000000', paper: '#FFFFFF', beam: '#FFFFFF', cloud: '#C4C4C4', far: '#868686', glass: '#FFFFFF' }], + ]), + // every colour becomes a grey between the ink and the paper, as light as it was + tint: c => mix(INK, PAPER, lum(c)), + ribbon: brush, print: wash, boil: 0, reg: [2.2, 1.7], + // the section's caption, over everything; out at sea and under the page's title card there's none + after(s, c) { + const g = s.back, z = captionAt(g), out = (1 - smooth(0, 0.15, s.dive)) * s.labelsK(); + if (out <= 0.01) return; + c.font = `italic 400 ${z.size}px ${CASLON}`; c.textAlign = 'center'; c.textBaseline = 'alphabetic'; c.fillStyle = ink; + for (const k of Hill.states) { + if (s.pres[k] < 0.01) continue; + // in as few lines as fit, about as long as each other + const text = CAPTIONS[k], all = c.measureText(text).width, n = Math.ceil(all / (z.x1 - z.x0 - 48 * g.u)), ls = ['']; + for (const w of text.split(' ')) { + const line = ls.at(-1); + if (line && ls.length < n && c.measureText(line + ' ' + w).width > all / n * 1.1) ls.push(w); + else ls[ls.length - 1] = line ? line + ' ' + w : w; + } + c.globalAlpha = out * s.pres[k]; + ls.forEach((l, i) => c.fillText(l, (z.x0 + z.x1) / 2, z.foot - (ls.length - 1 - i) * z.lh)); + } + c.globalAlpha = 1; + }, + draw: { + // the paper, the sky's wash and the sea's, streaked sideways + sky(base, s, c) { + const g = s.g, { W, H } = g; + wash(c, [[-20, -20], [W + 20, -20], [W + 20, g.horizon + 2], [-20, g.horizon + 2]], s.P.sky); + wash(c, [[-20, g.horizon], [W + 20, g.horizon], [W + 20, H + 20], [-20, H + 20]], s.P.sea, SEA); + }, + // the lower sky, washed lighter, and the horizon in one stroke + band(base, s, c, bandTop) { + const g = s.g, { W, u } = g, by = bandTop + 20 * u, band = []; + for (let x = -30; x <= W + 30; x += 16 * u) band.push([x, by + Math.sin(x * 0.006 + 1) * 11 * u + Math.sin(x * 0.019 + 2) * 3 * u]); + band.push([W + 30, g.horizon + 4], [-30, g.horizon + 4]); + // (on the line, with no white between it and the sky's) + poly(c, band); c.fillStyle = paper; c.fill(); + c.globalAlpha = Math.min(1, (1 - lum(s.P.sky2)) / 0.72); c.fillStyle = WASH; c.fill(); c.globalAlpha = 1; + c.beginPath(); brush(c, dens([[-10, g.horizon], [W + 10, g.horizon]], 8), Hill.DET, 2, { tin: 40, tout: 40, w0: 1, w1: 1 }); c.fillStyle = Hill.INK; c.fill(); + }, + // a few stars, left white + stars(base, s, c) { + const g = s.g, u = g.u, st = smooth(0.55, 0.9, s.d); + if (st <= 0) return; + c.beginPath(); + for (const p of g.stars) { + if (p.s < 1.15) continue; + const r = p.s * 2.4 * u; + for (const a of [p.p, p.p + Math.PI / 2]) brush(c, [[p.x - Math.cos(a) * r, p.y - Math.sin(a) * r], [p.x + Math.cos(a) * r, p.y + Math.sin(a) * r]], 1.7, 0, { tin: 1, tout: 1, w0: 1, w1: 1 }); + } + c.fillStyle = rgba(s.P.beam, st); c.fill(); + }, + // the sea: a few strokes of the pen across the water, longer and further apart as they come closer, each the + // brush's sliver: pointed, thin where it lands, thinnest where it lifts. Far off, where they're a hair thick, a + // plain dash, all of them in one path of rectangles + waves(base, s, c) { + const g = s.g, { W, H, u } = g, hz = g.horizon, sea = H - hz, mid = W / 2, go = s.dive * Hill.DIVE_GAP / Hill.SHORE, ROW = 0.11, span = 150 * u, PW = Hill.PW; + const near = new Path2D(), far = new Path2D(); + for (let k = Math.ceil((1 + go) / ROW); ; k++) { + const rel = 1 / (k * ROW - go); + if (rel < 0.035) break; + const y = hz + sea * rel, keep = 0.75 * rel ** 1.5, q = k % 128, w = Hill.DET * Math.max(0.5, rel), hw = w * PW * (w <= TEX * 1.2 ? 0.28 : 0.46), dy = 0.6 * u * rel; + for (let j = Math.floor(-mid / rel / span) - 1; j * span * rel < mid; j++) { + if (hash(q * 31.7 + j * 7.3) > keep) continue; + const x = mid + (j + hash(j * 3.1 + q)) * span * rel, l = Math.max(4, (30 + 60 * hash(q + j * 1.7)) * u * rel); + if (rel < 0.25) { far.rect(x, y + dy / 2 - hw * 0.7, l, hw * 1.4); continue; } + const a = Math.min(l * 0.45, 9.6 * PW), b = l - Math.min(l * 0.5, 19.2 * PW), ya = y + dy * a / l, yb = y + dy * b / l; + near.moveTo(x - hw * 0.3, y); near.lineTo(x, y - hw * 0.3); near.lineTo(x + a, ya - hw); near.lineTo(x + b, yb - hw); near.lineTo(x + l, y + dy - hw * 0.1); + near.lineTo(x + l + hw * 0.1, y + dy); near.lineTo(x + l, y + dy + hw * 0.1); near.lineTo(x + b, yb + hw); near.lineTo(x + a, ya + hw); near.lineTo(x, y + hw * 0.3); + near.closePath(); + } + } + c.fillStyle = Hill.INK; c.fill(near); c.fill(far); + // and none of it where the caption goes, while there is one + const out = 1 - smooth(0, 0.15, s.dive); + if (out > 0) lift(c, s.back, out); + }, + // the page's hill, its washes lifted off under the caption + hill(base, s, c) { + base(s, c); + if (s.g.isle || s.g.portrait) return; + c.save(); poly(c, s.g.hillClip); c.clip(); lift(c, s.g, 1); c.restore(); + }, + // the light: two strokes, and between them paper the wash was kept off + beam(base, s, c) { + const g = s.g, pw = s.lampPower(), ga = c.globalAlpha, { L, len, a0, a1 } = Hill.beamPoly(s); + if (len < 10 || pw < 0.02) return; + const fan = [[L.x, L.y]]; + for (let i = 0; i <= 12; i++) { const a = lerp(a0, a1, i / 12); fan.push([L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]); } + poly(c, fan); c.fillStyle = paper; c.globalAlpha = ga * 0.95 * pw; c.fill(); + const r0 = g.lh.er * 2.3; + c.beginPath(); + for (const [a, sd] of [[a0, 13], [a1, 17]]) brush(c, dens([[L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0], [L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]], 9), Hill.DET, sd, { tin: 20, tout: 60, w0: 0.4, w1: 0.4 }); + c.globalAlpha = ga * (0.3 + 0.7 * pw); c.fillStyle = Hill.INK; c.fill(); c.globalAlpha = 1; + }, + // where the light lands on the hill, the wash lifted off it + hillLight(base, s, c) { + const { L, len, a0, a1 } = Hill.beamPoly(s); + if (len < 10 || Math.sin(s.beam.a) < 0.08) return; + c.save(); poly(c, s.g.hillClip); c.clip(); + c.beginPath(); c.moveTo(L.x, L.y); + for (let i = 0; i <= 12; i++) { const a = lerp(a0, a1, i / 12); c.lineTo(L.x + Math.cos(a) * len, L.y + Math.sin(a) * len); } + c.closePath(); c.globalAlpha *= (0.4 + 0.5 * smooth(0.3, 0.8, s.d)) * s.lampPower(); c.fillStyle = paper; c.fill(); + c.restore(); + }, + // grass along the ridge near the lighthouse and past it; down the long slope a cartoonist leaves it out + tufts(base, s, c) { + const T = s.g.lh; + c.save(); c.beginPath(); c.rect(T.x - T.h * 0.6, 0, s.g.W, s.g.H); c.clip(); base(s, c); c.restore(); + }, + // the arrowed labels give way to the captions + labels() {}, + // the sounds in small capitals, lettered by hand; the grazers chew without comment + doodles(base, s, c) { + const u = s.g.u; + for (const d of s.doodles) { + const a = s.t - d.t0, f = 1 - smooth(d.life - 0.3, d.life, a); + if (f <= 0 || d.kind === 'munch') continue; + c.globalAlpha = f; + if (d.kind === 'note') Hill.quaver(c, d.x + Math.sin(a * 4 + d.seed) * 5 * u, d.y - a * 36 * u, 3.6 * u + 1.4, Hill.INK, d.flip); + else Hill.label(c, (d.text || d.kind).toUpperCase(), d.x, d.y - (s.reduced ? 0 : a * 6 * u), Math.max(10, 11 * u + 2), Hill.INK, d.align, -0.04); + } + c.globalAlpha = 1; + }, + }, +}); +})(); diff --git a/site/styles/clay.js b/site/styles/clay.js new file mode 100644 index 00000000..16e07c49 --- /dev/null +++ b/site/styles/clay.js @@ -0,0 +1,432 @@ +/* Lighthouse Hill in clay stop-motion, for Catppuccin (see the styles in scene.js): a model set in plasticine, lit + from the upper left and shot on twos. Nothing is outlined: the fills are lumps of clay, lit and thumbed, and the + lines that are things of their own (legs, grass, the lid, smoke, the arrows) are rolled strands of it. */ +(() => { +const H = Hill, { TAU, clamp, lerp, smooth, hash, noise, rng, hex, mix, mul, rgba, poly, ellipse, dens, curve, rank } = H; +const WHITE = hex('#FFFFFF'), DARK = hex('#302D41'); +// the fleece's balls, back to front, in the sheep's units +const FLEECE = [[-6, -23.5], [0, -24.5], [6, -23.5], [-11, -18], [-5.5, -18.5], [0.5, -19], [6, -18.5], [11, -17.5], [-8.5, -12], [-3, -11.5], [2.5, -11.5], [8, -12]]; + +/* the clay's skin, faint, the same all over: smears where a thumb slid, a few whorls of its print pressed in, and + crumbs; drawn round the tile's edges too, so it repeats without a seam */ +const PRINTS = (() => { + const cv = document.createElement('canvas'), x = cv.getContext('2d'), R = rng(41), S = 200; + cv.width = cv.height = S; + const wrap = draw => { for (const dx of [-S, 0, S]) for (const dy of [-S, 0, S]) { x.save(); x.translate(dx, dy); draw(); x.restore(); } }; + x.lineCap = 'round'; + for (let i = 0; i < 70; i++) { + const cx = R() * S, cy = R() * S, a = R() * TAU, l = 3 + R() * 7, w = 3 + R() * 4, col = R() < 0.5 ? `rgba(48,45,65,${0.025 + R() * 0.03})` : `rgba(255,255,255,${0.05 + R() * 0.05})`; + wrap(() => { x.beginPath(); x.moveTo(cx, cy); x.quadraticCurveTo(cx + Math.cos(a) * l * 0.5 + Math.sin(a) * 2, cy + Math.sin(a) * l * 0.5 - Math.cos(a) * 2, cx + Math.cos(a) * l, cy + Math.sin(a) * l); x.lineWidth = w; x.strokeStyle = col; x.stroke(); }); + } + for (let i = 0; i < 3; i++) { + const cx = R() * S, cy = R() * S, rot = R() * TAU, arcs = []; + for (let k = 2; k < 7; k++) { const a = R() * TAU; arcs.push([k * 1.5, a, a + 2 + R() * 2.5, 0.04 + R() * 0.04]); } + wrap(() => { for (const [r, a0, a1, al] of arcs) { x.beginPath(); x.ellipse(cx, cy, r, r * 0.75, rot, a0, a1); x.lineWidth = 0.7; x.strokeStyle = `rgba(48,45,65,${al})`; x.stroke(); } }); + } + for (let i = 0; i < 260; i++) { x.fillStyle = R() < 0.5 ? 'rgba(48,45,65,0.1)' : 'rgba(255,255,255,0.14)'; x.beginPath(); x.arc(R() * S, R() * S, 0.3 + R() * 0.7, 0, TAU); x.fill(); } + return x.createPattern(cv, 'repeat'); +})(); +/* a soft shadow on the ground, under what stands there */ +const BLOB = document.createElement('canvas'); +BLOB.width = BLOB.height = 64; +{ + const x = BLOB.getContext('2d'), gr = x.createRadialGradient(32, 32, 0, 32, 32, 32); + gr.addColorStop(0, 'rgba(48,45,65,0.42)'); gr.addColorStop(0.55, 'rgba(48,45,65,0.2)'); gr.addColorStop(1, 'rgba(48,45,65,0)'); + x.fillStyle = gr; x.fillRect(0, 0, 64, 64); +} +const shadow = (c, x, y, rx, ry) => c.drawImage(BLOB, x - rx, y - ry, rx * 2, ry * 2); + +/* clay lit from (x, y): a soft highlight there, its own colour, then darker toward the far side */ +function lit(c, x, y, r, col) { + const gr = c.createRadialGradient(x, y, 0, x, y, r); + gr.addColorStop(0, rgba(mix(col, WHITE, 0.45))); gr.addColorStop(0.15, rgba(mix(col, WHITE, 0.2))); + gr.addColorStop(0.5, rgba(col)); gr.addColorStop(1, rgba(mix(col, DARK, 0.42))); + return gr; +} +/* a lump of clay: the shape, nudged a little each frame as by a hand, lit across its size; its edge rounds over, + lighter where it turns to the light and darker where it turns away; and prints in it */ +function lump(c, pts, col) { + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const [x, y] of pts) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); } + const j = 0.35 * H.PW, b = H.BOIL * 2.3, e = Math.min(2.4 * H.PW, (x1 - x0) * 0.1, (y1 - y0) * 0.1), p = new Path2D(); + pts.forEach(([x, y], i) => p.lineTo(x + noise(i * 0.31 + b) * j, y + noise(i * 0.31 + b + 7) * j)); + p.closePath(); + c.fillStyle = typeof col === 'string' ? col : lit(c, lerp(x0, x1, 0.3), lerp(y0, y1, 0.26), Math.hypot(x1 - x0, y1 - y0) * 0.72, col); + c.fill(p); + c.save(); c.clip(p); + c.lineWidth = e * 2; c.lineJoin = 'round'; + c.translate(e, e); c.strokeStyle = 'rgba(255,255,255,0.28)'; c.stroke(p); + c.translate(-e * 2, -e * 2); c.strokeStyle = rgba(DARK, 0.2); c.stroke(p); + c.globalAlpha *= 0.8; c.fillStyle = PRINTS; c.fill(p); + c.restore(); +} +/* a ball of clay */ +function ball(c, x, y, r, col) { + c.beginPath(); c.arc(x, y, r, 0, TAU); c.fillStyle = lit(c, x - r * 0.4, y - r * 0.45, r * 1.6, col); c.fill(); +} + +/* A rolled strand along a path, W wide: its shadow on what it lies on, the strand, a darker underside and a highlight + along its top */ +function rolled(c, p, W, col) { + c.save(); + c.shadowColor = 'transparent'; c.lineCap = 'round'; c.lineJoin = 'round'; + c.translate(W * 0.3, W * 0.45); c.lineWidth = W; c.strokeStyle = 'rgba(48,45,65,0.22)'; c.stroke(p); + c.translate(-W * 0.3, -W * 0.45); c.strokeStyle = col; c.stroke(p); + c.translate(W * 0.06, W * 0.2); c.lineWidth = W * 0.5; c.strokeStyle = 'rgba(48,45,65,0.22)'; c.stroke(p); + c.translate(-W * 0.18, -W * 0.44); c.lineWidth = W * 0.3; c.strokeStyle = 'rgba(255,255,255,0.45)'; c.stroke(p); + c.restore(); +} +/* The line, rolled in clay as it comes, in STRAND or the ink ('fill' takes the colour the caller has on), so the + caller's fill finds nothing. Outlines aren't drawn. */ +let STRAND = null; +function strand(c, pts, w, sd = 0, o = {}) { + if (o.outline) return; + const p = new Path2D(), j = 0.3 * H.PW, b = H.BOIL * 3.7 + sd; + pts.forEach(([x, y], i) => p.lineTo(x + noise(i * 0.4 + b) * j, y + noise(i * 0.4 + b + 9) * j)); + rolled(c, p, w * H.PW, STRAND === 'fill' ? c.fillStyle : STRAND || H.INK); +} + +/* The sea is a sheet of clay smoothed with a tool: its marks run across in rows, closer together toward the horizon, + each a ridge lit along its top over a groove. The rows are the felt pen's scallops' (see waveScallops), so diving + you go over them as fast. */ +function ridges(s, c, top, groove) { + const g = s.g, { W, u } = g, hz = g.horizon, sea = g.H - hz, ROW = 0.02, go = s.dive * H.DIVE_GAP / H.SHORE, alpha = c.globalAlpha; + for (let k = Math.ceil((1 + go) / ROW); ;) { + const rel = 1 / (k * ROW - go); + if (rel < 0.0175) break; + const f = rel ** (1 / 1.6), gap = 3.2 * sea * rel ** 0.375 / g.rows, dy = sea * ROW * rel * rel; + const step = 2 ** Math.max(0, Math.ceil(Math.log2(gap / dy))); + if (k % step) { k += step - k % step; continue; } + const a = rank(k) > Math.log2(step) ? 1 : clamp(dy * step / gap - 1); + k += step; + if (a <= 0) continue; + const q = k - step, row = { y: hz + sea * rel, f, seed: q * 97 + s.o.seed }, lw = (1.4 + 4.2 * f) * g.pw, hi = new Path2D(), lo = new Path2D(); + for (let i = 0; i < 5 + Math.round(4 * (1 - f)); i++) { + const x0 = (hash(q * 0.13 + i * 0.71) * 1.3 - 0.15) * W, l = (24 + 150 * f) * Math.max(u, 0.66) * (0.5 + hash(q * 0.29 + i * 1.3)); + for (let m = 0; m <= 6; m++) { + const x = x0 + l * m / 6, y = s.waveY(x, row) - Math.sin(m / 6 * Math.PI) * l * 0.04, to = m ? 'lineTo' : 'moveTo'; + hi[to](x, y - lw * 0.35); lo[to](x, y + lw * 0.5); + } + } + c.globalAlpha = alpha * a; + c.lineWidth = lw * 1.2; c.strokeStyle = groove; c.stroke(lo); + c.lineWidth = lw * 0.75; c.strokeStyle = top; c.stroke(hi); + } + c.globalAlpha = alpha; +} + +Hill.style('catppuccin', { + // Latte by day, Mocha at night: pastel clay under a soft sky + pal: H.stops([ + [0, { sky: '#A6D8F2', sky2: '#DDE6FA', sea: '#6F9BEF', sea2: '#B0CAFB', foam: '#EFF1F5', hill: '#98D894', hill2: '#76BD7C', ink: '#4C4F69', paper: '#FFFBF8', beam: '#FFDD6E', cloud: '#F4F1F4', far: '#B4BEFE', glass: '#DCE7FA' }], + [0.2, { sky: '#B9C2F5', sky2: '#F2D5E3', sea: '#6A8FE6', sea2: '#AEC0F8', foam: '#F5F0F2', hill: '#90D18E', hill2: '#70B878', ink: '#4C4F69', paper: '#FFF7F2', beam: '#FFDD6E', cloud: '#F8EEF0', far: '#C6B9F2', glass: '#F2D5E3' }], + [0.44, { sky: '#F5AE8A', sky2: '#F5C2E7', sea: '#6B7DE0', sea2: '#B4BEFE', foam: '#F5E0DC', hill: '#84BF88', hill2: '#66A276', ink: '#4A4563', paper: '#FBEDE6', beam: '#FFDD6E', cloud: '#F2CDCD', far: '#D59BC4', glass: '#FAB387' }], + [0.7, { sky: '#7F6BC0', sky2: '#C38BB8', sea: '#3E4A9A', sea2: '#7F8BDA', foam: '#D6CCF2', hill: '#4E8463', hill2: '#3E6E55', ink: '#2F2E45', paper: '#E3DCF5', beam: '#F9E2AF', cloud: '#B4A6E6', far: '#5E5594', glass: '#F9D98A' }], + [1, { sky: '#272A45', sky2: '#363A5E', sea: '#1F2442', sea2: '#4A5585', foam: '#A6ADC8', hill: '#36594A', hill2: '#2A4839', ink: '#1E1E2E', paper: '#C9CFEA', beam: '#F9E2AF', cloud: '#5B6080', far: '#313457', glass: '#F9E2AF' }], + ]), + C: { + wool: hex('#F5F0EE'), yolk: hex('#F7BE5A'), duck: hex('#F7D774'), beak: hex('#FAB387'), pink: hex('#F5C2E7'), + monster: hex('#EBA0AC'), chimney: hex('#F38BA8'), roof: hex('#6C6F93'), door: hex('#89B4FA'), skin: hex('#F5D5C0'), + navy: hex('#5B6BC0'), wood: hex('#D9A27A'), steel: hex('#CCD0DA'), moon: hex('#F9E2AF'), tile: hex('#45475A'), + huts: ['#F38BA8', '#89B4FA', '#F9E2AF', '#A6E3A1'].map(hex), + kraft: hex('#E3B98C'), kraftLit: hex('#F0CDA5'), kraftDark: hex('#C69768'), tape: hex('#F9E2AF'), sprout: hex('#7ACB7E'), + }, + ribbon: strand, print: lump, reg: [0, 0], pen: 1.5, boil: 12, fps: 12, + hand: 'ui-rounded, "SF Pro Rounded", "Arial Rounded MT Bold", sans-serif', weight: 700, + + draw: { + // the backdrop, painted soft, and the sea's clay sheet, darker near + sky(base, s, c) { + const g = s.g, P = s.P, sky = c.createLinearGradient(0, g.top, 0, g.horizon), sea = c.createLinearGradient(0, g.horizon, 0, g.H); + sky.addColorStop(0, rgba(mix(P.sky, DARK, 0.06))); sky.addColorStop(1, rgba(mix(P.sky, P.sky2, 0.45))); + sea.addColorStop(0, rgba(mix(P.sea, P.sea2, 0.3))); sea.addColorStop(1, rgba(mix(P.sea, DARK, 0.18))); + c.fillStyle = sky; c.fillRect(0, 0, g.W, g.horizon + 2); + c.fillStyle = sea; c.fillRect(0, g.horizon, g.W, g.H - g.horizon); + }, + // the lower sky, a lighter strip of the painted backdrop, and the sea's far edge rolled over + band(base, s, c, bandTop) { + const g = s.g, { W, u } = g, P = s.P, by = bandTop + 20 * u, band = []; + for (let x = -30; x <= W + 30; x += 16 * u) band.push([x, by + Math.sin(x * 0.006 + 1) * 11 * u + Math.sin(x * 0.019 + 2) * 3 * u]); + band.push([W + 30, g.horizon + 4], [-30, g.horizon + 4]); + poly(c, band); c.fillStyle = rgba(P.sky2); c.fill(); + STRAND = rgba(mix(P.sea, P.sea2, 0.7)); strand(c, dens([[-10, g.horizon + 1], [W + 10, g.horizon + 1]], 30), H.DET * 1.2); STRAND = null; + }, + // little stars cut from yellow clay, and crumbs of it between them + stars(base, s, c) { + const g = s.g, u = g.u, st = smooth(0.55, 0.9, s.d); + if (st <= 0) return; + c.beginPath(); + for (const p of g.stars) { + if (p.s < 0.75) { c.moveTo(p.x + u, p.y); c.arc(p.x, p.y, 0.9 * u + 0.4, 0, TAU); continue; } + const r = p.s * 3.4 * u * (s.reduced ? 1 : 0.85 + 0.15 * Math.sin(s.t * 2 + p.p)); + for (let i = 0; i < 10; i++) { const a = p.p + i / 10 * TAU, q = i % 2 ? r * 0.48 : r; c[i ? 'lineTo' : 'moveTo'](p.x + Math.cos(a) * q, p.y + Math.sin(a) * q); } + c.closePath(); + } + c.fillStyle = rgba(s.P.beam, st); c.fill(); + c.beginPath(); + for (const p of g.stars) if (p.s >= 0.75) { const r = p.s * 0.9 * u; c.moveTo(p.x - r * 0.5 + r, p.y - r * 0.6); c.arc(p.x - r * 0.5, p.y - r * 0.6, r, 0, TAU); } + c.fillStyle = `rgba(255,255,255,${0.55 * st})`; c.fill(); + }, + waves(base, s, c) { ridges(s, c, rgba(mix(s.P.sea, s.P.sea2, 0.75)), rgba(mix(s.P.sea, DARK, 0.3))); }, + // where things land: a flattened ring of foam spreading + ripples(base, s, c) { + const u = s.g.u, P = s.P; + for (const r of s.ripples) { + if (!H.afloat(s, c, r.y)) continue; + const age = s.t - r.t0, rx = 8 * u + age * 70 * u, p = new Path2D(); + p.ellipse(r.x, r.y, rx, rx * 0.3, 0, 0, TAU); + c.globalAlpha = clamp(1 - age / 2.2) * r.a; rolled(c, p, H.DET * 0.9 * H.PW, rgba(P.foam)); c.globalAlpha = 1; + } + }, + // a fried egg in clay: a flattened white, a domed yolk with its shadow on it, and a shine + sun(base, s, c) { + const g = s.g, u = g.u, sun = s.sunPos(); + if (sun.a <= 0) return; + const r = 38 * u, sz = r * 3; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); + c.globalAlpha = sun.a; + s.sprite('sun', sz, sz, sun.x, sun.y, x => { + const white = []; + for (let i = 0; i < 48; i++) { const a = i / 48 * TAU; white.push([Math.cos(a) * r * (1 + 0.12 * Math.sin(a * 3 + 1) + 0.06 * Math.sin(a * 5 + 2)), Math.sin(a) * r * 0.86 * (1 + 0.09 * Math.sin(a * 4))]); } + lump(x, white, mul(WHITE, s.P.paper)); + shadow(x, 6 * u, 2 * u, r * 0.55, r * 0.5); + ball(x, 3 * u, -3 * u, r * 0.42, H.C.yolk); + x.beginPath(); x.ellipse(3 * u - r * 0.15, -3 * u - r * 0.2, r * 0.13, r * 0.08, -0.6, 0, TAU); x.fillStyle = 'rgba(255,255,255,0.75)'; x.fill(); + }); + c.restore(); + }, + // a cloud is lumps of clay pressed together on a flat base, each rounded in the light; the one with legs walks + cloud(base, s, c, cl, name, legs) { + const g = s.g, u = g.u, P = s.P, p = s.cloudXY(cl), w = cl.w; + if (p.x < -w || p.x > g.W + w) return; + if (legs) { + const walk = s.reduced ? 0 : s.t * 5, lp = new Path2D(); + [-0.24, -0.11, 0.1, 0.23].forEach((lx, i) => { + const x = p.x + lx * w * (cl.dir < 0 ? -1 : 1), sw = Math.sin(walk + i * 1.7 + (i % 2) * Math.PI) * 2.6 * u; + lp.moveTo(x, p.y - 4 * u); lp.lineTo(x + sw * 0.5, p.y + 5 * u); lp.lineTo(x + sw, p.y + 12 * u); + }); + rolled(c, lp, H.DET * 1.6 * H.PW, H.INK); + } + s.sprite(name + (legs && cl.dir < 0 ? 'L' : ''), w * 1.3, w * 0.75, p.x, p.y - w * 0.12, x => { + const R = rng(cl.seed), n = 4 + ((R() * 2) | 0), lumps = []; + for (let i = 0; i < n; i++) { + const fx = (i + 0.5) / n, r = w * (0.12 + R() * 0.07) * (1.3 - Math.abs(fx - 0.5)); + lumps.push([-w / 2 + fx * w + (R() - 0.5) * w * 0.06, w * 0.1 - r * 0.62, r]); + } + x.beginPath(); + for (const [cx, cy, r] of lumps) { x.moveTo(cx + r, cy); x.arc(cx, cy, r, 0, TAU); } + x.moveTo(w * 0.53, w * 0.08); x.ellipse(0, w * 0.08, w * 0.53, w * 0.06, 0, 0, TAU); + x.fillStyle = lit(x, -w * 0.2, -w * 0.18, w * 0.75, P.cloud); x.fill(); + x.save(); x.globalAlpha = 0.55; x.fillStyle = PRINTS; x.fill(); x.restore(); + for (const [cx, cy, r] of lumps) { x.beginPath(); x.arc(cx - r * 0.3, cy - r * 0.35, r * 0.55, 0, TAU); x.fillStyle = 'rgba(255,255,255,0.16)'; x.fill(); } + // a tail: it might be a sheep + if (legs) ball(x, (cl.dir < 0 ? 1 : -1) * w * 0.53, 0, w * 0.05, P.cloud); + }); + }, + // the light: a cone of yellow cellophane, crinkled into facets that catch it one way or the other, the sea's + // ridges lit through it + beam(base, s, c) { + const P = s.P, I = s.beamI(), pw = s.lampPower(), { L, len, a0, a1 } = H.beamPoly(s); + if (len < 10 || pw < 0.02) return; + const night = smooth(0.3, 0.8, s.d), col = mix(P.beam, hex('#FFF6C2'), night * 0.45), n = 7, b = H.BOIL * 0.9; + const at = (a, r) => [L.x + Math.cos(a) * r, L.y + Math.sin(a) * r]; + const crease = i => { const a = lerp(a0, a1, i / n), k = i % n ? 0.35 / n : 0; return [[L.x, L.y], at(a + noise(i * 1.7 + b) * k * (a1 - a0), len * 0.5), at(a + noise(i * 2.3 + b + 4) * k * (a1 - a0), len)]; }; + const fan = [[L.x, L.y]]; + for (let i = 0; i <= 12; i++) fan.push(at(lerp(a0, a1, i / 12), len)); + for (let i = 0; i < n; i++) { + poly(c, [...crease(i), ...crease(i + 1).reverse()]); + c.fillStyle = rgba(i % 2 ? mix(col, WHITE, 0.3) : col, (0.78 + 0.14 * night * I + (i % 2 ? 0.08 : 0) - hash(i) * 0.1) * pw); c.fill(); + } + c.save(); poly(c, fan); c.clip(); c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ridges(s, c, rgba(mix(col, WHITE, 0.6), 0.9 * pw), rgba(mix(col, P.sea, 0.45), 0.5 * pw)); + c.restore(); + // the creases catch the light + const p = new Path2D(); + for (let i = 1; i < n; i++) if (hash(i * 3.1) > 0.35) { const e = crease(i); p.moveTo(...at(lerp(a0, a1, i / n), s.g.lh.er * 2.4)); p.lineTo(...e[1]); p.lineTo(...e[2]); } + c.lineWidth = 1.4 * H.PW; c.strokeStyle = `rgba(255,255,255,${0.5 * pw})`; c.stroke(p); + }, + // the lit lantern at night: a glow round it, and a disc of light round the eye + halo(base, s, c) { + const L = s.g.lamp, er = s.g.lh.er, P = s.P, night = smooth(0.35, 0.85, s.d), f = s.beam.flash, k = Math.max(night * s.beamI(), f * 0.9) * s.lampOn() * s.lampPower(); + if (k < 0.02) return; + const r = er * (4.6 + f * 1.4), gr = c.createRadialGradient(L.x, L.y, er, L.x, L.y, r); + gr.addColorStop(0, rgba(P.beam, clamp(k * 0.55))); gr.addColorStop(1, rgba(P.beam, 0)); + c.beginPath(); c.arc(L.x, L.y, r, 0, TAU); c.fillStyle = gr; c.fill(); + c.globalAlpha = clamp(k * 1.4); + lump(c, ellipse(L.x, L.y, er * 1.75, er * 1.6, 30), mix(P.beam, WHITE, 0.25)); + c.globalAlpha = 1; + }, + // the hill: a big lump of green clay smoothed by thumbs, the shade on its flank toward the sea, a lip of light along + // its top, a rolled strand of foam where it meets the water, and daisies of little balls + hill(base, s, c) { + const g = s.g, { W, u } = g, P = s.P, T = g.lh, rd = g.ridge, R = rng(5); + if (g.isle) { + const sl = g.shore, x0 = sl[0][0], x1 = sl.at(-1)[0], deep = x => Math.sin(Math.PI * (x - x0) / (x1 - x0)) * 18 * u; + lump(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x, y + deep(x)])], mix(P.sea, P.hill2, 0.45)); + } + lump(c, g.hillPoly, P.hill); + if (g.shore) { + const sl = g.shore, x0 = sl[0][0]; + lump(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x + 4 * u, y - (8 + (x - x0) / W * 26) * u])], P.hill2); + } else { + const fl = c.createLinearGradient(rd[0][0], g.H, T.x - T.wB * 2, s.ridgeY(T.x - T.wB * 2)); + fl.addColorStop(0, rgba(P.hill2, 0.95)); fl.addColorStop(0.45, rgba(P.hill2, 0.45)); fl.addColorStop(1, rgba(P.hill2, 0)); + poly(c, g.hillPoly); c.fillStyle = fl; c.fill(); + } + // thumb marks: a smoothed ridge of clay over a groove + const hi = new Path2D(), lo = new Path2D(); + for (let i = 0; i < (g.portrait ? 9 : 16); i++) { + const x = lerp(rd[0][0] + 40 * u, W - 30 * u, R()), ry = s.ridgeY(x), l = (18 + R() * 26) * u; + if (ry == null) { R(); continue; } + const y = ry + (16 + R() * (g.portrait ? 40 : 160)) * u; + if (g.shore && y > s.shoreY(x) - 12 * u) continue; + hi.moveTo(x, y); hi.quadraticCurveTo(x + l / 2, y - l * 0.16, x + l, y + l * 0.08); + lo.moveTo(x, y + 2 * u); lo.quadraticCurveTo(x + l / 2, y - l * 0.16 + 2 * u, x + l, y + l * 0.08 + 2 * u); + } + c.save(); poly(c, g.hillPoly); c.clip(); + const top = Math.min(...rd.map(p => p[1])), sh = c.createLinearGradient(0, top, 0, g.shore ? s.shoreY(T.x) : g.H); + sh.addColorStop(0, 'rgba(255,255,255,0.2)'); sh.addColorStop(0.3, 'rgba(255,255,255,0)'); sh.addColorStop(1, rgba(DARK, 0.22)); + c.fillStyle = sh; c.fill(); + c.lineCap = 'round'; c.lineWidth = 2.6 * u; + c.strokeStyle = rgba(mix(P.hill2, DARK, 0.2), 0.3); c.stroke(lo); + c.strokeStyle = 'rgba(255,255,255,0.22)'; c.stroke(hi); + const lip = new Path2D(); + rd.filter(p => p[0] > -20 && p[0] < W + 20).forEach(([x, y], i) => lip[i ? 'lineTo' : 'moveTo'](x, y + 2.4 * u)); + c.lineWidth = 3.4 * u; c.strokeStyle = 'rgba(255,255,255,0.3)'; c.stroke(lip); + c.restore(); + const foam = g.shore ? g.shore.map(([x, y]) => [x, y + 2 * u]) : rd.filter(p => p[1] > g.horizon + 3 && p[0] < T.x).map(([x, y]) => [x - 2 * u, y]); + STRAND = rgba(P.foam); strand(c, foam.filter(p => p[0] < W + 20), H.DET * 1.5, 23); STRAND = null; + const D = rng(9); + for (let i = 0; i < (g.portrait ? 6 : 11); i++) { + const x = lerp(T.x - T.h * 0.42, Math.min(W - 10, g.cot.x + g.cot.w * 1.6), D()), ry = s.ridgeY(x); + if (ry == null || Math.abs(x - T.x) < T.wB * 0.8 || Math.abs(x - g.cot.x) < g.cot.w * 0.6) { D(); continue; } + const y = ry + (8 + D() * (g.portrait ? 20 : 46)) * u, r = 2.3 * u; + for (let k = 0; k < 5; k++) { const a = k / 5 * TAU; ball(c, x + Math.cos(a) * r, y + Math.sin(a) * r, 1.6 * u, mul(WHITE, P.paper)); } + ball(c, x, y, 1.5 * u, H.C.yolk); + } + }, + // the cottage stands on the hill; its roof is clay like the rest, not inked + cottage(base, s, c) { + const g = s.g, u = g.u, k = g.cot, x0 = k.x - k.w / 2, y0 = k.base - k.h; + shadow(c, k.x + k.w * 0.1, k.base + 1 * u, k.w * 0.75, 5 * u); + base(s, c); + lump(c, [[x0 - k.w * 0.12, y0 + 1 * u], [x0 + k.w * 0.5, y0 - k.h * 0.78], [x0 + k.w * 1.12, y0 + 1 * u]], H.C.roof); + }, + // the tower: a round column of white clay with bands of the theme's pressed round it, lit down its left side, + // in shade down its right; dark clay for the door, the windows and the gallery + tower(base, s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, th = H.THEMES[g.theme || s.theme]; + const half = k => lerp(T.wB, T.wT, k) / 2, yAt = k => T.base - T.h * k, Lp = [], Rp = []; + for (let i = 0; i <= 20; i++) { const k = i / 20; Lp.push([T.x - half(k), yAt(k)]); Rp.push([T.x + half(k), yAt(k)]); } + const body = [...Lp, ...Rp.reverse()], box = [T.x - T.wB / 2, T.top - 2, T.wB, T.h + 4]; + shadow(c, T.x + T.wB * 0.3, T.base + 1 * u, T.wB * 1.05, 5.5 * u); + c.save(); poly(c, body); c.clip(); + c.fillStyle = rgba(mul(WHITE, P.paper)); c.fillRect(...box); + const bands = 6, sag = T.wB * 0.09, seams = new Path2D(); + const edge = k => { const hw = half(k), out = []; for (let i = 0; i <= 12; i++) { const f = i / 12 * 2 - 1; out.push([T.x + f * hw, yAt(k) - sag * (1 - f * f)]); } return out; }; + for (let j = 1, n = 0; j < bands; j += 2) { + const lo = edge(j / bands), hi = j + 1 >= bands ? [[T.x + half(1), yAt(1) - 2], [T.x - half(1), yAt(1) - 2]] : edge((j + 1) / bands).reverse(); + poly(c, [...lo, ...hi]); c.fillStyle = rgba(mul(hex(th.stripes[n++ % th.stripes.length]), P.paper)); c.fill(); + } + for (let j = 1; j < bands; j++) edge(j / bands).forEach(([x, y], i) => seams[i ? 'lineTo' : 'moveTo'](x, y + 0.8 * u)); + c.lineWidth = 1.4 * u; c.strokeStyle = rgba(DARK, 0.14); c.stroke(seams); + const sh = c.createLinearGradient(T.x - T.wB / 2, 0, T.x + T.wB / 2, 0); + sh.addColorStop(0, 'rgba(255,255,255,0.32)'); sh.addColorStop(0.3, 'rgba(255,255,255,0)'); sh.addColorStop(0.58, rgba(DARK, 0)); sh.addColorStop(1, rgba(DARK, 0.4)); + c.fillStyle = sh; c.fillRect(...box); + c.globalAlpha = 0.55; c.fillStyle = PRINTS; c.fillRect(...box); + c.restore(); + const win = k => { const y = yAt(k), w = T.wB * 0.22, h = T.er * 0.75; return [[T.x - w / 2, y - h / 2], [T.x + w / 2, y - h / 2], [T.x + w / 2, y + h / 2], [T.x - w / 2, y + h / 2]]; }; + for (const k of [0.25, 0.58]) lump(c, win(k), smooth(0.45, 0.75, s.d) > 0.5 ? P.beam : H.C.roof); + const dw = T.wB * 0.32, dh = T.wB * 0.5; + lump(c, [[T.x - dw / 2, T.base], ...ellipse(T.x, T.base - dh + dw / 2, dw / 2, dw / 2, 10, Math.PI, TAU), [T.x + dw / 2, T.base]], H.C.roof); + const gw = T.wT * 0.95; + lump(c, [[T.x - gw, T.top + 1 * u], [T.x - gw * 1.05, T.top - T.gal * 0.42], [T.x + gw * 1.05, T.top - T.gal * 0.42], [T.x + gw, T.top + 1 * u]], P.ink); + }, + // grass along the ridge, in rolled strands of darker green + tufts(base, s, c) { + const g = s.g, u = g.u, p = new Path2D(); + for (const b of g.tufts) { + let a = s.reduced ? 0.12 : Math.sin(s.t * 1.6 + b.p) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (s.ptr.on && !s.reduced) { + const dx = b.x - s.ptr.x, dy = b.y - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; + if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); + } + a = clamp(a, -1.3, 1.3); + for (let k = 0; k < b.n + 1; k++) { + const da = (k - b.n / 2) * 0.42, l = b.l * (k === 1 ? 1.15 : 0.85), x = b.x + (k - b.n / 2) * 1.6 * u, y = b.y + 1.5 * u; + const tx = x + Math.sin(a + da) * l, ty = y - Math.cos(a + da) * l; + p.moveTo(x, y); p.quadraticCurveTo(lerp(x, tx, 0.5), lerp(y, ty, 0.55), tx, ty); + } + } + rolled(c, p, H.TEX * 1.4 * H.PW, rgba(mix(s.P.hill2, DARK, 0.25))); + }, + meadow(base, s, c, m) { + STRAND = rgba(mix(s.P.hill2, H.C.sprout, 0.85 * (1 - smooth(0.4, 0.9, s.d)))); base(s, c, m); STRAND = null; + }, + smoke(base, s, c) { STRAND = rgba(mix(s.P.cloud, s.P.ink, 0.2), 0.9); base(s, c); STRAND = null; }, + splashes(base, s, c) { STRAND = rgba(s.P.foam); base(s, c); STRAND = null; }, + // what stands on the hill stands in its own shadow + obj(base, s, c, o) { + if (o.state === 'land' && (o.kind === 'sheep' || o.kind === 'cow')) { const k = s.g.u * o.sc; shadow(c, o.x + 2 * k, o.y, 19 * k, 3.6 * k); } + base(s, c, o); + }, + keeper(base, s, c, x, y, sc, ...rest) { shadow(c, x + 1.5 * s.g.u * sc, y, 9 * s.g.u * sc, 2.4 * s.g.u * sc); base(s, c, x, y, sc, ...rest); }, + // a sheep: a fleece of small clay balls on four strands of legs, a dark clay face + sheep(base, c, u, wool, o, t) { + const fall = o.state === 'fall' || o.state === 'leap', step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 14 + o.seed) * 2 * u : 0, legs = new Path2D(); + [-8, -3, 5, 10].forEach((lx, i) => { legs.moveTo(lx * u, -10 * u); legs.lineTo(lx * u + (fall ? 3 * u : (i % 2 ? step : -step)), 0); }); + rolled(c, legs, H.DET * 1.25 * H.PW, H.INK); + lump(c, ellipse(0, -16 * u, 13.5 * u, 9 * u, 20), mix(wool, DARK, 0.18)); + const ja = H.joyAt(o, t); + c.save(); c.translate(-14 * u, -19 * u); c.rotate(-0.45 + (ja >= 0 ? Math.sin(ja * 24) * 0.6 * (1 - ja / 1.3) : 0)); + ball(c, -2.4 * u, 0, 3 * u, wool); + c.restore(); + for (const [x, y] of FLEECE) ball(c, (x + noise(x + o.seed + H.BOIL) * 0.5) * u, (y + noise(y + o.seed + H.BOIL) * 0.5) * u, 4.3 * u, wool); + H.head(c, u, o, t, 15, -19, [19, -7.5], 1.05, chew => { + const face = mul(hex('#585B70'), wool); + lump(c, ellipse(15 * u, -19 * u, 5 * u, 6.4 * u, 18, 0, TAU, -0.35), face); + lump(c, ellipse(11.5 * u, -23.5 * u, 3.6 * u, 1.6 * u, 10, 0, TAU, 0.5 + chew * 0.25), face); + for (const [x, y] of [[11.8, -24.6], [14.6, -25.8], [17.2, -24.6]]) ball(c, x * u, y * u, 2.3 * u, wool); + STRAND = '#FFFFFF'; + if (ja >= 0) strand(c, ellipse(16.6 * u, -19.6 * u, 1.7 * u, 1.3 * u, 8, Math.PI * 1.1, Math.PI * 1.9), H.TEX, 4); + else if (H.NAP) H.napEye(c, 16.6 * u, -20.5 * u, 1.5 * u, '#FFFFFF'); + else { H.dot(c, 16.6 * u, -20.5 * u, 1.6 * u, '#FFFFFF'); H.dot(c, 17.1 * u, -20.3 * u, 0.8 * u, H.INK); } + STRAND = null; + }); + }, + // the sea monster: humps and a neck of clay, foam rolled round where they go into the water + monster(base, c, u, pink, P, o, t) { + const sw = Math.sin(t * 1.6 + o.seed); + STRAND = rgba(P.foam); + for (let i = 0; i < 3; i++) { + const hx = (-30 + i * 19) * u, hh = (8 + 2.5 * Math.sin(t * 2 + i * 1.3)) * u * (0.85 + 0.15 * sw); + lump(c, [...ellipse(hx, 1 * u, 8.5 * u, hh, 14, Math.PI, TAU), [hx + 8.5 * u, 1 * u]], pink); + strand(c, [[hx - 11 * u, 1.5 * u], [hx, 0], [hx + 11 * u, 1.5 * u]], H.TEX, i + 4); + } + lump(c, curve([[20 * u, 1 * u], [22 * u, -10 * u], [27 * u, -20 * u], [34 * u, -25 * u], [42 * u, -26 * u], [48 * u, -21 * u], [46 * u, -16 * u], [38 * u, -16 * u], [33 * u, -11 * u], [31 * u, 1 * u]], 2), pink); + strand(c, [[16 * u, 1.5 * u], [26 * u, 0], [36 * u, 1.5 * u]], H.TEX, 8); + STRAND = null; + ball(c, 41 * u, -21.5 * u, 2.8 * u, mul(WHITE, P.paper)); + H.dot(c, 41.8 * u, -21.3 * u, 1.1 * u, H.INK); + strand(c, [[45 * u, -18 * u], [47 * u, -17.6 * u]], H.TEX, 10); + // its little top hat, with a red band + lump(c, [[36 * u, -25.5 * u], [37 * u, -35 * u], [45 * u, -35.5 * u], [45.5 * u, -26 * u]], P.ink); + c.fillStyle = rgba(mul(H.C.chimney, P.paper)); c.fillRect(36.6 * u, -29.5 * u, 8.6 * u, 2 * u); + strand(c, [[33 * u, -25.8 * u], [49 * u, -26.4 * u]], H.DET * 1.2, 2); + }, + // the labels and the doodles: letters cut from clay, standing a little off the backdrop + labels(base, s, c) { + STRAND = 'fill'; + c.save(); c.shadowColor = rgba(DARK, 0.3); c.shadowOffsetX = c.shadowOffsetY = 1.4 * s.g.pw; + base(s, c); + c.restore(); STRAND = null; + }, + doodles(base, s, c) { + STRAND = rgba(mix(s.P.ink, s.P.paper, smooth(0.5, 0.7, s.d))); + c.save(); c.shadowColor = rgba(DARK, 0.3); c.shadowOffsetX = c.shadowOffsetY = 1.2 * s.g.pw; + base(s, c); + c.restore(); STRAND = null; + }, + }, +}); +})(); diff --git a/site/styles/papercut.js b/site/styles/papercut.js new file mode 100644 index 00000000..c597363c --- /dev/null +++ b/site/styles/papercut.js @@ -0,0 +1,403 @@ +/* Lighthouse Hill in paper cut, for Nord (see the styles in scene.js): a diorama of cut paper photographed straight + on. No lines: every shape is a flat colour with a scissor-cut edge, casting a soft shadow down and to the right on + whatever is behind it, all on a faint paper fibre. A stroke that stands on its own (a leg, a blade of grass, a bar, + an arrow) is a thin strip of paper; one that goes round a shape is that shape's edge and goes. The layers shift a + few pixels with the pointer, the near ones most. */ +(() => { +const { TAU, clamp, lerp, smooth, hash, noise, rng, hex, mix, mul, rgba, ellipse, poly, cloudPts, dens, dot, label, beamPoly, waveScallops } = Hill; +const MOSS = hex('#A3BE8C'), AURORA = [MOSS, hex('#8FBCBB'), hex('#B48EAD')], VELLUM = hex('#FFF7DA'); +const SHADOW = 'rgba(14,18,28,0.15)'; + +/* ---------- the paper ---------- */ +// the polygon added to the path, moved by (dx, dy) +function trace(c, pts, dx, dy) { + c.moveTo(pts[0][0] + dx, pts[0][1] + dy); + for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0] + dx, pts[i][1] + dy); + c.closePath(); +} +// the edge the scissors left: each corner a hair off where it was meant to be +const snip = pts => { const n = pts.length, j = Hill.PW * 0.9; return pts.map(([x, y], i) => [x + (hash(i * 1.3 + n) - 0.5) * j, y + (hash(i * 2.1 + n) - 0.5) * j]); }; +/* A shape cut out of paper and laid on, `deep` layers above what's behind it: its shadow is two copies, a little and + a little more down and to the right, darker where they overlap */ +function paper(c, pts, col, deep = 1) { + const k = Hill.PW * deep, cut = snip(pts); + c.beginPath(); trace(c, cut, k, k * 1.5); c.fillStyle = SHADOW; c.fill(); + c.beginPath(); trace(c, cut, k * 2.2, k * 3.3); c.fill(); + c.beginPath(); trace(c, cut, 0, 0); c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill(); +} +/* The same in the cached layers, where a blurred shadow costs nothing after the first time; it shows a little above a + top edge too. The canvas's shadow is in device pixels, so it's scaled by the layer's. */ +function soft(c, pts, col, deep = 1, holes = []) { + const m = c.getTransform().a, k = Hill.PW * deep * m; + c.save(); + c.shadowColor = 'rgba(14,18,28,0.4)'; c.shadowBlur = 3 * k + 2 * m; c.shadowOffsetX = 1.3 * k; c.shadowOffsetY = 2 * k; + c.beginPath(); trace(c, snip(pts), 0, 0); + for (const h of holes) trace(c, h, 0, 0); + c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill('evenodd'); + c.restore(); +} +/* a strip of paper along the points, w wide (times PW), added to the path for the caller to fill; its shadow is laid + down at once */ +function strip(c, pts, w) { + const n = pts.length, hw = w * Hill.PW * 0.55, ring = new Array(n * 2); + for (let i = 0; i < n; i++) { + const a = pts[i > 0 ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : n - 1]; + let tx = b[0] - a[0], ty = b[1] - a[1]; + const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; + ring[i] = [pts[i][0] - ty * hw, pts[i][1] + tx * hw]; ring[n * 2 - 1 - i] = [pts[i][0] + ty * hw, pts[i][1] - tx * hw]; + } + const sh = new Path2D(), k = Hill.PW, fill = c.fillStyle; + trace(sh, ring, k * 0.9, k * 1.4); + c.fillStyle = SHADOW; c.fill(sh); c.fillStyle = fill; + trace(c, ring, 0, 0); +} +/* The felt pen's lines, in paper: an outline is the cut itself, the others are strips */ +let bare = false; +function ribbon(c, pts, w, sd, o = {}) { + if (!bare && !o.outline) strip(c, pts, w); +} +// letters cut out of paper: a crisp shadow just under them +function lift(s, c) { const k = s.dpr; c.shadowColor = 'rgba(14,18,28,0.4)'; c.shadowBlur = 1.5 * k; c.shadowOffsetX = 0.8 * k; c.shadowOffsetY = 1.2 * k; } + +/* The diorama's depth: each layer moves with the pointer as far as it's near, from the sky (still) to the hill. Every + layer goes right and down only, so none leaves a gap at the screen's right or bottom edge. */ +const lean = { x: 0, y: 0 }, STARS = 0.3, SEA = 0.45, CLOUDS = 0.6, LAND = 1; +const shift = (c, s, depth) => { const a = 3.2 * s.g.u * depth; c.translate(a * (1 - lean.x), a * 0.6 * (1 - lean.y)); }; + +/* The paper's fibre: a tile of short curled hairs, lighter and darker, laid over the whole diorama */ +const FIBRE = (() => { + const cv = document.createElement('canvas'), x = cv.getContext('2d'), n = 256, R = rng(19); + cv.width = cv.height = n; + x.lineWidth = 0.6; + for (let i = 0; i < 900; i++) { + const px = R() * n, py = R() * n, a = R() * TAU, l = 3 + R() * 9; + x.strokeStyle = R() < 0.5 ? 'rgba(255,255,255,0.06)' : 'rgba(20,24,36,0.06)'; + x.beginPath(); x.moveTo(px, py); x.quadraticCurveTo(px + Math.cos(a + 0.6) * l * 0.5, py + Math.sin(a + 0.6) * l * 0.5, px + Math.cos(a) * l, py + Math.sin(a) * l); x.stroke(); + } + return x.createPattern(cv, 'repeat'); +})(); + +Hill.style('nord', { + pal: Hill.stops([ + [0, { sky: '#B8D6E0', sky2: '#E5E9F0', sea: '#5E81AC', sea2: '#88C0D0', foam: '#ECEFF4', hill: '#ECEFF4', hill2: '#C6D0DE', ink: '#3B4252', paper: '#F4F6F9', beam: '#EBCB8B', cloud: '#F4F6F9', far: '#A9C7D3', glass: '#8FBCBB' }], + [0.2, { sky: '#B3CAD8', sky2: '#E8E1E7', sea: '#5A7BA6', sea2: '#8BB4CA', foam: '#ECEFF4', hill: '#E9EAF0', hill2: '#C3C9D8', ink: '#3B4252', paper: '#F2F0F2', beam: '#EBCB8B', cloud: '#F2EFF2', far: '#B3BCD3', glass: '#A9C4D2' }], + [0.44, { sky: '#B48EAD', sky2: '#D08770', sea: '#4C6A92', sea2: '#A08FB2', foam: '#E8D9E2', hill: '#E6D6D8', hill2: '#BFAFC0', ink: '#3B4252', paper: '#F0DEDA', beam: '#EBCB8B', cloud: '#EBD0CF', far: '#A58AA6', glass: '#EBCB8B' }], + [0.7, { sky: '#4C566A', sky2: '#6A6B88', sea: '#3B4A63', sea2: '#6C86AE', foam: '#B9C3D6', hill: '#A6AFC2', hill2: '#7F889E', ink: '#2E3440', paper: '#BAC2D1', beam: '#EBCB8B', cloud: '#677189', far: '#4C566A', glass: '#EBCB8B' }], + [1, { sky: '#2E3440', sky2: '#3B4252', sea: '#36405A', sea2: '#5E81AC', foam: '#81A1C1', hill: '#76819A', hill2: '#56607A', ink: '#242933', paper: '#9AA5BA', beam: '#EBCB8B', cloud: '#4C566A', far: '#3B4252', glass: '#EBCB8B' }], + ]), + C: { + wool: hex('#FFFFFF'), yolk: hex('#EBCB8B'), duck: hex('#EBCB8B'), beak: hex('#D08770'), pink: hex('#E3A9A9'), + monster: hex('#B48EAD'), chimney: hex('#BF616A'), roof: hex('#3B4252'), door: hex('#5E81AC'), skin: hex('#EAC6AA'), + navy: hex('#434C5E'), wood: hex('#C9A57A'), steel: hex('#D8DEE9'), moon: hex('#EBCB8B'), tile: hex('#2E3440'), + huts: ['#BF616A', '#5E81AC', '#EBCB8B', '#A3BE8C'].map(hex), + kraft: hex('#C9A57A'), kraftLit: hex('#DDBF97'), kraftDark: hex('#A8865E'), tape: hex('#EBCB8B'), sprout: hex('#A3BE8C'), + }, + ribbon, print: paper, boil: 0, reg: [0, 0], + // the labels and doodles in Nord's bar font, medium + hand: '"Avenir Next", "Avenir", "Helvetica Neue", sans-serif', weight: 500, + before(s) { + const p = s.cursor, on = p.on && !s.reduced; + lean.x = lerp(lean.x, on ? clamp(p.x / s.W * 2 - 1, -1, 1) : 0, 0.1); + lean.y = lerp(lean.y, on ? clamp(p.y / s.H * 2 - 1, -1, 1) : 0, 0.1); + }, + + after(s, c) { c.fillStyle = FIBRE; c.fillRect(0, 0, s.W, s.H); }, + draw: { + /* the sky, and at night an aurora: three long strips of paper, green, teal and violet, waving slowly */ + sky(base, s, c) { + base(s, c); + const g = s.g, { W, u, top } = g, k = smooth(0.6, 0.95, s.d), t = s.reduced ? 0 : s.t, sky = g.horizon - top; + if (k <= 0) return; + c.save(); shift(c, s, STARS); c.globalAlpha = k; + AURORA.forEach((col, i) => { + const x0 = g.portrait ? -20 : W * 0.3, y0 = top + sky * (g.portrait ? 0.2 + i * 0.07 : 0.1 + i * 0.075), ring = new Array(98); + for (let j = 0; j <= 48; j++) { + const f = j / 48, x = lerp(x0, W + 20, f), y = y0 + Math.sin(f * 5 + i * 1.7 + t * 0.35) * 16 * u + Math.sin(f * 11 - t * 0.5 + i) * 5 * u - f * sky * 0.08; + const hw = (5 + 4 * Math.sin(f * 7 + i * 2 + t * 0.6)) * u * Math.sin(Math.PI * f) ** 0.4 + 1; + ring[j] = [x, y - hw]; ring[97 - j] = [x, y + hw]; + } + paper(c, ring, mix(col, s.P.sky, 0.25)); + }); + c.restore(); + }, + /* the lower sky: two lighter sheets cut in soft waves, and the sea's edge laid over them at the horizon */ + band(base, s, c, bandTop) { + const g = s.g, { W, u } = g, P = s.P, by = bandTop + 20 * u, band = [], low = []; + for (let x = -30; x <= W + 30; x += 12 * u) { + band.push([x, by + Math.sin(x * 0.006 + 1) * 11 * u + Math.sin(x * 0.019 + 2) * 3 * u]); + low.push([x, lerp(by, g.horizon, 0.55) + Math.sin(x * 0.011 + 4) * 6 * u + Math.sin(x * 0.027) * 2 * u]); + } + band.push([W + 30, g.horizon + 4], [-30, g.horizon + 4]); + low.push([W + 30, g.horizon + 4], [-30, g.horizon + 4]); + soft(c, band, P.sky2, 2); + soft(c, low, mix(P.sky2, P.far, 0.35), 1.5); + c.fillStyle = rgba(P.sea); c.fillRect(-1, g.horizon, W + 2, 8 * u); + c.fillStyle = rgba(mix(P.sea, P.foam, 0.45)); c.fillRect(-1, g.horizon, W + 2, Hill.PW); + }, + /* small paper stars, five points each, that swell and shrink a little */ + stars(base, s, c) { + const g = s.g, u = g.u, st = smooth(0.55, 0.9, s.d); + if (st <= 0) return; + const sh = new Path2D(), star = new Path2D(), k = Hill.PW; + for (const p of g.stars) { + const r = p.s * (p.s < 0.75 ? 1.6 : 3.2) * u * (s.reduced ? 1 : 0.8 + 0.2 * Math.sin(s.t * 2 + p.p)), pts = []; + for (let i = 0; i < 10; i++) { const a = i / 10 * TAU + p.p, rr = i % 2 ? r * 0.45 : r; pts.push([p.x + Math.sin(a) * rr, p.y - Math.cos(a) * rr]); } + trace(sh, pts, k * 0.8, k * 1.2); trace(star, pts, 0, 0); + } + c.save(); shift(c, s, STARS); c.globalAlpha = st; + c.fillStyle = SHADOW; c.fill(sh); + c.fillStyle = rgba(mix(s.P.beam, VELLUM, 0.4)); c.fill(star); + c.restore(); + }, + /* the waves: thin strips of paper laid on the sea */ + waves(base, s, c) { + const k = Hill.PW; + c.save(); c.lineCap = 'butt'; shift(c, s, SEA); + c.translate(k * 0.8, k * 1.3); c.strokeStyle = SHADOW; waveScallops(s, c, false); + c.translate(-k * 0.8, -k * 1.3); c.strokeStyle = rgba(s.P.sea2); waveScallops(s, c, false); + c.restore(); + }, + /* rings of paper where things land */ + ripples(base, s, c) { + const u = s.g.u, k = Hill.PW; + c.lineWidth = 2 * k; + for (const r of s.ripples) { + if (!Hill.afloat(s, c, r.y)) continue; + const age = s.t - r.t0, rx = 8 * u + age * 70 * u; + c.globalAlpha = clamp(1 - age / 2.2) * r.a; + c.beginPath(); c.ellipse(r.x + k, r.y + k * 1.5, rx, rx * 0.3, 0, 0, TAU); c.strokeStyle = SHADOW; c.stroke(); + c.beginPath(); c.ellipse(r.x, r.y, rx, rx * 0.3, 0, 0, TAU); c.strokeStyle = rgba(s.P.foam); c.stroke(); + } + c.globalAlpha = 1; + }, + /* the fried egg, cut out: the white, the yolk on it, a sliver of shine */ + sun(base, s, c) { + const g = s.g, u = g.u, sun = s.sunPos(); + if (sun.a <= 0) return; + const r = 38 * u, sz = r * 3; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); shift(c, s, STARS); + c.globalAlpha = sun.a; + s.sprite('sun', sz, sz, sun.x, sun.y, x => { + const white = []; + for (let i = 0; i < 48; i++) { const a = i / 48 * TAU; white.push([Math.cos(a) * r * (1 + 0.12 * Math.sin(a * 3 + 1) + 0.06 * Math.sin(a * 5 + 2)), Math.sin(a) * r * 0.86 * (1 + 0.09 * Math.sin(a * 4))]); } + soft(x, white, mul(hex('#FFFFFF'), s.P.paper), 2); + soft(x, ellipse(3 * u, -3 * u, r * 0.42, r * 0.4, 30), mul(Hill.C.yolk, s.P.paper), 1.2); + x.beginPath(); strip(x, ellipse(3 * u, -3 * u, r * 0.27, r * 0.25, 10, Math.PI * 1.15, Math.PI * 1.5), 2.2); x.fillStyle = '#FFFFFF'; x.fill(); + }); + c.restore(); + }, + /* the crescent moon, asleep: its shut eye and its smile are strips of paper; a z drifts off */ + moon(base, s, c) { + const g = s.g, u = g.u, m = s.moonPos(); + if (m.a <= 0) return; + const r = 25 * u, sz = r * 3.4; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); shift(c, s, STARS); + c.globalAlpha = m.a; + s.sprite('moon', sz, sz, m.x, m.y, x => { + const cres = []; + for (let i = 0; i <= 26; i++) { const a = -Math.PI / 2 + i / 26 * Math.PI; cres.push([Math.cos(a) * r, Math.sin(a) * r]); } + for (let i = 25; i >= 1; i--) { const a = -Math.PI / 2 + i / 26 * Math.PI; cres.push([Math.cos(a) * r * 0.38 + r * 0.02, Math.sin(a) * r * 0.97]); } + soft(x, cres, Hill.C.moon, 2); + x.beginPath(); + strip(x, ellipse(r * 0.62, -r * 0.2, r * 0.17, r * 0.11, 9, 0.15 * Math.PI, 0.85 * Math.PI), 1.6); + strip(x, ellipse(r * 0.52, r * 0.3, r * 0.15, r * 0.1, 9, 0.2 * Math.PI, 0.8 * Math.PI), 1.5); + x.fillStyle = Hill.INK; x.fill(); + }); + const zz = s.reduced ? 0.4 : (s.t * 0.4) % 1; + label(c, 'z', m.x + r * 1.1 + zz * 10 * u, m.y - r * 0.7 - zz * 16 * u, (11 + zz * 5) * u + 3, rgba(mix(s.P.paper, s.P.beam, 0.3), m.a * (1 - zz) * 0.95), 'left', -0.2); + c.restore(); + }, + /* a cloud: two sheets, the one behind a shade cooler; the one with legs walks on strips of paper */ + cloud(base, s, c, cl, name, legs) { + const g = s.g, u = g.u, P = s.P, p = s.cloudXY(cl), w = cl.w; + if (p.x < -w || p.x > g.W + w) return; + c.save(); shift(c, s, CLOUDS); + if (legs) { + const walk = s.reduced ? 0 : s.t * 5; + c.beginPath(); + [-0.24, -0.11, 0.1, 0.23].forEach((lx, i) => { + const x = p.x + lx * w * (cl.dir < 0 ? -1 : 1), sw = Math.sin(walk + i * 1.7 + (i % 2) * Math.PI) * 2.6 * u; + strip(c, [[x, p.y - 4 * u], [x + sw * 0.5, p.y + 5 * u], [x + sw, p.y + 12 * u]], Hill.DET * 1.25); + }); + c.fillStyle = Hill.INK; c.fill(); + } + s.sprite(name + (legs && cl.dir < 0 ? 'L' : ''), w * 1.3, w * 0.75, p.x, p.y - w * 0.12, x => { + soft(x, dens(cloudPts(-w * 0.06, w * 0.06, w * 0.8, cl.seed + 1), 5, true), mix(P.cloud, P.sky, 0.3), 1.5); + soft(x, dens(cloudPts(0, w * 0.12, w, cl.seed), 5, true), P.cloud, 2); + if (legs) { + // a tail: it might be a sheep + const f = cl.dir < 0 ? -1 : 1; + soft(x, ellipse(-f * w * 0.5, w * 0.05, w * 0.045, w * 0.035, 12), P.cloud); + } + }); + c.restore(); + }, + /* the light: a wedge of pale yellow vellum, the sea's strips showing through it a little lighter; in the dark the + vellum is paler and the night shows through it */ + beam(base, s, c) { + const P = s.P, pw = s.lampPower(), { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || pw < 0.02) return; + const night = smooth(0.3, 0.8, s.d), fan = [[L.x, L.y]], k = Hill.PW, a = c.globalAlpha, col = mix(P.beam, VELLUM, 0.4 + 0.35 * night); + for (let i = 0; i <= 12; i++) { const an = lerp(a0, a1, i / 12); fan.push([L.x + Math.cos(an) * len, L.y + Math.sin(an) * len]); } + c.save(); shift(c, s, LAND); + c.globalAlpha = a * pw * 0.3; poly(c, fan, true, k * 2, k * 3); c.fillStyle = SHADOW; c.fill(); + c.globalAlpha = a * pw * (0.6 - 0.38 * night); poly(c, fan); c.fillStyle = rgba(col); c.fill(); + + c.globalAlpha = a; + poly(c, fan); c.clip(); c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); shift(c, s, SEA); + c.lineCap = 'butt'; c.strokeStyle = rgba(mix(col, hex('#FFFFFF'), 0.4), 0.9 * pw); waveScallops(s, c, true); + c.restore(); + }, + /* the lamp lit at night: two discs of vellum round the eye */ + halo(base, s, c) { + const g = s.g, L = g.lamp, er = g.lh.er, night = smooth(0.35, 0.85, s.d), f = s.beam.flash; + const k = Math.max(night * s.beamI(), f * 0.9) * s.lampOn() * s.lampPower(), a = c.globalAlpha; + if (k < 0.02) return; + c.save(); shift(c, s, LAND); c.fillStyle = rgba(mix(s.P.beam, VELLUM, 0.5)); + for (const [r, o] of [[3.4 + f * 1.4, 0.28], [2.3 + f * 0.6, 0.4]]) { + c.globalAlpha = a * clamp(k) * o; + poly(c, snip(ellipse(L.x, L.y, er * r, er * r, 40))); c.fill(); + } + c.restore(); + }, + /* where the light lands on the hill, the vellum over the snow */ + hillLight(base, s, c) { + const { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || Math.sin(s.beam.a) < 0.08) return; + c.save(); poly(c, s.g.hillClip); c.clip(); + c.beginPath(); c.moveTo(L.x, L.y); + for (let i = 0; i <= 12; i++) { const a = lerp(a0, a1, i / 12); c.lineTo(L.x + Math.cos(a) * len, L.y + Math.sin(a) * len); } + c.closePath(); c.fillStyle = rgba(mix(s.P.beam, VELLUM, 0.4), (0.4 + 0.15 * smooth(0.3, 0.8, s.d)) * s.lampPower()); c.fill(); + c.restore(); + }, + land(base, s, c, w) { c.save(); shift(c, s, LAND); base(s, c, w); c.restore(); }, + /* the hill: green paper under a sheet of snow, the snow torn open here and there; a shady sheet over its flank, a + band of rock under a headland's edge, and strips of foam where it meets the sea */ + hill(base, s, c) { + const g = s.g, { W, H, u } = g, P = s.P, T = g.lh, rd = g.ridge; + if (g.isle) { + const sl = g.shore, x0 = sl[0][0], x1 = sl.at(-1)[0], deep = x => Math.sin(Math.PI * (x - x0) / (x1 - x0)) * 18 * u, R = rng(3); + poly(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x, y + deep(x)])]); c.fillStyle = rgba(mix(P.sea, P.hill2, 0.35)); c.fill(); + c.beginPath(); + for (let j = 0; j < 4; j++) for (let x = x0 + R() * 30 * u; x < x1 - 20 * u; x += (24 + R() * 30) * u) { + const y = s.shoreY(x) + deep(x) * (0.25 + j * 0.27) + 2 * u; + strip(c, [[x, y], [x + (6 + R() * 12) * u * (1 - j * 0.15), y]], 1.2); + } + c.fillStyle = rgba(P.sea2); c.fill(); + } + soft(c, g.hillPoly, mul(MOSS, P.paper), 3); + // the holes in the snow, where the grass shows: under the ridge, clear of the tower and the shore + const R = rng(7), holes = []; + for (let i = 0; i < (g.isle ? 4 : g.portrait ? 5 : 7); i++) { + const x = lerp(rd[0][0] + 30 * u, Math.min(W, rd.at(-1)[0]) - 20 * u, R()), y = s.ridgeY(x) + (g.isle ? 14 + R() * 18 : 26 + R() * (g.portrait ? 40 : 150)) * u; + const rx = (g.isle ? 10 : 18 + R() * 26) * u, hole = []; + if (Math.abs(x - T.x) < T.wB * 1.4 || y + rx * 0.4 > s.shoreY(x) - 6 * u) continue; + for (let k = 0; k < 28; k++) { const a = k / 28 * TAU, rr = 1 + 0.32 * noise(k * 0.45 + i * 7) + 0.06 * noise(k * 1.7 + i); hole.push([x + Math.cos(a) * rx * rr, y + Math.sin(a) * rx * 0.45 * rr]); } + holes.push(hole); + } + c.save(); poly(c, g.hillPoly); c.clip(); + soft(c, g.hillPoly, P.hill, 2, holes); + c.restore(); + if (g.shore) { + const sl = g.shore, x0 = sl[0][0]; + soft(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x + 4 * u, y - (8 + (x - x0) / W * 26) * u])], mix(P.hill2, P.ink, 0.3), 1.5); + } else { + const sh = rd.filter(([x]) => x < T.x - T.wB * 2), n = sh.length; + const inner = sh.map(([x, y], i) => { const f = smooth(0, 0.35, (n - 1 - i) / n); return [x + (34 + (H - y) * 0.12) * u * f, y + (16 + (H - y) * 0.05) * u * f]; }).reverse(); + soft(c, [...sh, ...inner], P.hill2, 1.5); + } + c.beginPath(); + const foam = g.shore || rd.filter(p => p[1] > g.horizon + 3 && p[0] < T.x), ox = g.shore ? 0 : -4 * u, oy = g.shore ? 4 * u : 0; + for (let i = 0; i < foam.length - 4; i += 6) { + const [x, y] = foam[i + 2]; + if (x < -10 || x > W + 10 || y > H + 10 || hash(i * 0.7) < 0.3) continue; + strip(c, foam.slice(i, i + 4).map(([x, y]) => [x + ox, y + oy]), 1.8); + } + c.fillStyle = rgba(P.foam); c.fill(); + }, + /* a red cottage with white corners, snow along its roof, a stone chimney */ + cottage(base, s, c) { + const g = s.g, u = g.u, P = s.P, C = Hill.C, k = g.cot, w = k.w, h = k.h, x0 = k.x - w / 2, y0 = k.base - h; + const white = mul(hex('#FFFFFF'), P.paper), night = smooth(0.45, 0.75, s.d); + const chx = x0 + w * 0.68, chw = w * 0.15, cht = y0 - h * 1.45; + soft(c, [[chx, y0 - h * 0.2], [chx + 1.2 * u, cht], [chx + chw + 1.6 * u, cht], [chx + chw, y0 - h * 0.25]], mul(mix(C.steel, C.roof, 0.45), P.paper)); + soft(c, [[x0, k.base], [x0, y0], [x0 + w, y0], [x0 + w, k.base]], mul(C.chimney, P.paper), 1.5); + const ax = x0 + w * 0.5, ay = y0 - h * 0.78, roofY = x => ay + Math.abs(x - ax) / (w * 0.62) * (y0 + 1 * u - ay); + soft(c, [[x0 - w * 0.12, y0 + 1 * u], [ax, ay], [x0 + w * 1.12, y0 + 1 * u]], mul(C.roof, P.paper), 1.5); + const snow = [[x0 - w * 0.13, y0 + 0.5 * u], [ax, ay - 1.2 * u], [x0 + w * 1.13, y0 + 0.5 * u]]; + for (let i = 12; i >= 0; i--) { const x = lerp(x0 - w * 0.08, x0 + w * 1.08, i / 12); snow.push([x, roofY(x) + h * (0.13 + 0.05 * Math.sin(i * 1.9))]); } + soft(c, snow, white); + // the window: a white frame, the glass (lit at night), white bars + const wx = x0 + w * 0.14, wy = y0 + h * 0.24, ww = w * 0.27, wh = h * 0.36, f = 1.5 * u; + soft(c, [[wx - f, wy - f], [wx + ww + f, wy - f], [wx + ww + f, wy + wh + f], [wx - f, wy + wh + f]], white); + soft(c, [[wx, wy], [wx + ww, wy], [wx + ww, wy + wh], [wx, wy + wh]], night > 0.5 ? P.beam : P.glass, 0.5); + c.beginPath(); + strip(c, [[wx + ww / 2, wy], [wx + ww / 2, wy + wh]], 1.3); + strip(c, [[wx, wy + wh / 2], [wx + ww, wy + wh / 2]], 1.3); + strip(c, [[x0 + 1 * u, k.base], [x0 + 1 * u, y0]], 2.4); + strip(c, [[x0 + w - 1 * u, k.base], [x0 + w - 1 * u, y0]], 2.4); + c.fillStyle = rgba(white); c.fill(); + // the door (it opens for the keeper: see the states) + const dx = x0 + w * 0.56, dw = w * 0.24, dh = h * 0.66; + k.door = { x: dx, y: k.base - dh, w: dw, h: dh }; + soft(c, [[dx, k.base], [dx, k.base - dh], [dx + dw, k.base - dh], [dx + dw, k.base]], mul(C.door, P.paper)); + dot(c, dx + dw * 0.78, k.base - dh * 0.48, 1.3 * u + 0.4, Hill.INK); + }, + /* the tower: white paper, its stripes bands of the theme's paper laid on, curving up a little as seen from below */ + tower(base, s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, th = Hill.THEMES[g.theme || s.theme], C = Hill.C; + const half = k => lerp(T.wB, T.wT, k) / 2, yAt = k => T.base - T.h * k, Lp = [], Rp = []; + for (let i = 0; i <= 20; i++) { const k = i / 20; Lp.push([T.x - half(k), yAt(k)]); Rp.push([T.x + half(k), yAt(k)]); } + soft(c, [...Lp, ...Rp.reverse()], mul(hex('#FFFFFF'), P.paper), 2); + const bands = 6, sag = T.wB * 0.09; + const edge = k => { const hw = half(k), out = []; for (let i = 0; i <= 12; i++) { const f = i / 12 * 2 - 1; out.push([T.x + f * hw, yAt(k) - sag * (1 - f * f)]); } return out; }; + let n = 0; + for (let j = 1; j < bands; j += 2) { + const hi = j + 1 >= bands ? [[T.x + half(1), yAt(1)], [T.x - half(1), yAt(1)]] : edge((j + 1) / bands).reverse(); + soft(c, [...edge(j / bands), ...hi], mul(hex(th.stripes[n++ % th.stripes.length]), P.paper)); + } + const night = smooth(0.45, 0.75, s.d), ww = T.wB * 0.22, wh = T.er * 0.75; + for (const k of [0.25, 0.58]) { const y = yAt(k); soft(c, [[T.x - ww / 2, y - wh / 2], [T.x + ww / 2, y - wh / 2], [T.x + ww / 2, y + wh / 2], [T.x - ww / 2, y + wh / 2]], night > 0.5 ? P.beam : mul(C.roof, P.paper), 0.5); } + const dw = T.wB * 0.32, dh = T.wB * 0.5; + soft(c, [[T.x - dw / 2, T.base], ...ellipse(T.x, T.base - dh + dw / 2, dw / 2, dw / 2, 10, Math.PI, TAU), [T.x + dw / 2, T.base]], mul(C.roof, P.paper), 0.5); + // the gallery: a dark deck the eye sits on + const gw = T.wT * 0.95, gy = T.top; + soft(c, [[T.x - gw, gy + 1 * u], [T.x - gw * 1.05, gy - T.gal * 0.42], [T.x + gw * 1.05, gy - T.gal * 0.42], [T.x + gw, gy + 1 * u]], Hill.INK); + }, + /* grass along the ridge: slivers of green paper, leaning with the wind and away from the pointer */ + tufts(base, s, c) { + const g = s.g, u = g.u, k = Hill.PW, blades = new Path2D(); + for (const b of g.tufts) { + let a = s.reduced ? 0.12 : Math.sin(s.t * 1.6 + b.p) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (s.ptr.on && !s.reduced) { + const dx = b.x - s.ptr.x, dy = b.y - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; + if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); + } + a = clamp(a, -1.3, 1.3); + for (let j = 0; j < b.n + 1; j++) { + const da = (j - b.n / 2) * 0.42, l = b.l * (j === 1 ? 1.15 : 0.85), x = b.x + (j - b.n / 2) * 1.6 * u, y = b.y + 1.5 * u; + const tx = x + Math.sin(a + da) * l, ty = y - Math.cos(a + da) * l, w = 1.5 * g.pw; + blades.moveTo(x - w, y); blades.quadraticCurveTo(lerp(x, tx, 0.5) - w * 0.4, lerp(y, ty, 0.55), tx, ty); blades.quadraticCurveTo(lerp(x, tx, 0.5) + w * 0.4, lerp(y, ty, 0.55), x + w, y); blades.closePath(); + } + } + c.save(); c.translate(k * 0.8, k * 1.2); c.fillStyle = SHADOW; c.fill(blades); c.restore(); + c.fillStyle = rgba(mul(MOSS, s.P.paper)); c.fill(blades); + }, + /* smoke from the chimney: little puffs of paper that rise, swell and fade */ + smoke(base, s, c) { + if (s.reduced) return; + const g = s.g, u = g.u, k = g.cot, x0 = k.x - k.w / 2 + k.w * 0.68 + k.w * 0.075, y0 = k.base - k.h - k.h * 1.5; + for (let i = 0; i < 3; i++) { + const a = (s.t * 0.22 + i / 3) % 1, r = (2.5 + a * 6) * u; + c.globalAlpha = smooth(0, 0.08, a) * (1 - smooth(0.55, 1, a)); + paper(c, ellipse(x0 + s.windTotal * 50 * u * a + Math.sin(a * 6 + i) * 3 * u, y0 - 6 * u - a * 46 * u, r, r * 0.82, 14), mix(s.P.cloud, s.P.sky, 0.15)); + } + c.globalAlpha = 1; + }, + // the blades are paper already: the pen's strokes over them go + meadow(base, s, c, m) { bare = true; base(s, c, m); bare = false; }, + labels(base, s, c) { c.save(); lift(s, c); base(s, c); c.restore(); }, + doodles(base, s, c) { c.save(); lift(s, c); base(s, c); c.restore(); }, + }, +}); +})(); diff --git a/site/styles/poster.js b/site/styles/poster.js new file mode 100644 index 00000000..c0a6dde1 --- /dev/null +++ b/site/styles/poster.js @@ -0,0 +1,215 @@ +/* Lighthouse Hill in a screen-printed poster, for Gruvbox (see the styles in scene.js): a fat round brush line held + still, a few flat inks printed off the line, halftone dots for shade, and paper grain over it all */ +(() => { +const { clamp, lerp, smooth, noise, rng, hex, mix, mul, rgba, ellipse, poly, dens, dot, beamPoly, waveScallops, TAU } = Hill; +const RED = hex('#FB4934'), ORANGE = hex('#FE8019'), YELLOW = hex('#FABD2F'), CREAM = hex('#FBF1C7'); + +/* The dot screens: dark ink dots on a 45° grid, small, middling and heavy, drawn at four times the size and + shrunk back so they stay crisp on a sharp screen */ +const SCREEN = 7; +const screen = r => { + const cv = document.createElement('canvas'), x = cv.getContext('2d'), k = 4; + cv.width = cv.height = SCREEN * k; + x.fillStyle = '#282828'; + for (const [cx, cy] of [[0, 0], [SCREEN, 0], [0, SCREEN], [SCREEN, SCREEN], [SCREEN / 2, SCREEN / 2]]) { x.beginPath(); x.arc(cx * k, cy * k, r * k, 0, TAU); x.fill(); } + const p = x.createPattern(cv, 'repeat'); + p.setTransform(new DOMMatrix([1 / k, 0, 0, 1 / k, 0, 0])); + return p; +}; +const DOTS = { light: screen(0.8), mid: screen(1.3), deep: screen(1.8) }; +// a dot screen printed over the polygon, off the line like the other inks +function shade(c, pts, dots) { poly(c, pts, true, Hill.REGX, Hill.REGY); c.fillStyle = dots; c.fill(); } +// an animal's shady underside: light dots in the lower part of an ellipse round its body +function belly(c, cx, cy, rx, ry, y) { + c.save(); poly(c, ellipse(cx, cy, rx, ry, 24), true, Hill.REGX, Hill.REGY); c.clip(); + c.fillStyle = DOTS.light; c.fillRect(cx - rx, y, rx * 2, cy + ry - y + Hill.REGY); c.restore(); +} + +/* The paper: a tile of specks, lighter and darker, laid over the whole print */ +const GRAIN = (() => { + const cv = document.createElement('canvas'), x = cv.getContext('2d'), n = 256, R = rng(70); + cv.width = cv.height = n; + const im = x.createImageData(n, n), d = im.data; + for (let i = 0; i < n * n; i++) { + const r = R(), j = i * 4; + if (r < 0.1) { d[j] = 40; d[j + 1] = 36; d[j + 2] = 30; d[j + 3] = 16 + R() * 22; } + else if (r < 0.2) { d[j] = 251; d[j + 1] = 241; d[j + 2] = 199; d[j + 3] = 14 + R() * 20; } + } + x.putImageData(im, 0, 0); + return x.createPattern(cv, 'repeat'); +})(); + +/* The line: a fat round brush, the same width all along, with a little of the hand in it but no boil, and no + hatching (the dots shade) */ +function brush(c, pts, w, sd = 0, o = {}) { + const n = pts.length; + if (n < 2 || o.hatch) return; + const hw = w * Hill.PW * 0.5, l = [], r = []; + let s = 0; + for (let i = 0; i < n; i++) { + const a = pts[i > 0 ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : n - 1]; + let tx = b[0] - a[0], ty = b[1] - a[1]; + const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; + if (i) s += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); + const off = 0.3 * Hill.PW * noise(s / 80 + sd * 3.71), x = pts[i][0] - ty * off, y = pts[i][1] + tx * off; + l.push([x - ty * hw, y + tx * hw]); r.push([x + ty * hw, y - tx * hw]); + } + // round ends: half a turn from one side to the other + const cap = (p, q) => { + const cx = (p[0] + q[0]) / 2, cy = (p[1] + q[1]) / 2, a0 = Math.atan2(p[1] - cy, p[0] - cx); + for (let k = 1; k < 6; k++) c.lineTo(cx + Math.cos(a0 - k / 6 * Math.PI) * hw, cy + Math.sin(a0 - k / 6 * Math.PI) * hw); + }; + c.moveTo(l[0][0], l[0][1]); + for (let i = 1; i < n; i++) c.lineTo(l[i][0], l[i][1]); + cap(l[n - 1], r[n - 1]); + for (let i = n - 1; i >= 0; i--) c.lineTo(r[i][0], r[i][1]); + cap(r[0], l[0]); + c.closePath(); +} + +Hill.style('gruvbox', { + pal: Hill.stops([ + [0, { sky: '#EBDBB2', sky2: '#F7D488', sea: '#458588', sea2: '#83A598', foam: '#FBF1C7', hill: '#B8BB26', hill2: '#98971A', ink: '#282828', paper: '#FBF1C7', beam: '#FABD2F', cloud: '#FBF1C7', far: '#A89984', glass: '#83A598' }], + [0.2, { sky: '#F2CF8C', sky2: '#F6B860', sea: '#3F7A80', sea2: '#83A598', foam: '#FBF1C7', hill: '#AEAF24', hill2: '#8F8C19', ink: '#282828', paper: '#FBEBC0', beam: '#FABD2F', cloud: '#FBE6B4', far: '#B88A6A', glass: '#F7D488' }], + [0.44, { sky: '#D65D0E', sky2: '#FABD2F', sea: '#356A70', sea2: '#83A598', foam: '#F2DFB4', hill: '#98971A', hill2: '#7A7614', ink: '#282828', paper: '#F6E3B6', beam: '#FABD2F', cloud: '#F4B860', far: '#A85A3A', glass: '#FABD2F' }], + [0.7, { sky: '#8F3F71', sky2: '#CC241D', sea: '#2A4A50', sea2: '#5F8A86', foam: '#D5C4A1', hill: '#606114', hill2: '#4E4D0D', ink: '#1D2021', paper: '#EBDBB2', beam: '#FABD2F', cloud: '#B16286', far: '#5C3450', glass: '#FABD2F' }], + [1, { sky: '#282828', sky2: '#3C3836', sea: '#253A3C', sea2: '#458588', foam: '#A89984', hill: '#3F400D', hill2: '#343309', ink: '#1D2021', paper: '#D5C4A1', beam: '#FABD2F', cloud: '#504945', far: '#32302F', glass: '#FABD2F' }], + ]), + C: { + wool: hex('#FBF1C7'), yolk: hex('#FABD2F'), duck: hex('#FABD2F'), beak: hex('#FE8019'), pink: hex('#D3869B'), + monster: hex('#D3869B'), chimney: hex('#CC241D'), roof: hex('#3C3836'), door: hex('#458588'), skin: hex('#F2C48D'), + navy: hex('#3C3836'), wood: hex('#D65D0E'), steel: hex('#BDAE93'), moon: hex('#FABD2F'), tile: hex('#282828'), + huts: ['#CC241D', '#458588', '#D79921', '#98971A'].map(hex), + kraft: hex('#D79921'), kraftLit: hex('#FABD2F'), kraftDark: hex('#B57614'), tape: hex('#FB4934'), sprout: hex('#98971A'), + }, + ribbon: brush, pen: 1.6, boil: 0, reg: [3.4, 2.6], + hand: '"Rockwell", "Cooper Black", "Cooper", serif', + after(s, c) { c.fillStyle = GRAIN; c.fillRect(0, 0, s.g.W, s.g.H); }, + draw: { + // the sea goes darker in dots as it comes toward you + sky(base, s, c) { + base(s, c); + const g = s.g, sea = g.H - g.horizon; + [DOTS.light, DOTS.mid, DOTS.deep].forEach((d, i) => { c.fillStyle = d; c.fillRect(0, g.horizon + sea * (0.36 + i * 0.22), g.W, sea * 0.22); }); + }, + // the striped sunset: a disc in yellow, orange and red, cut by bands of sky that widen as it goes down + sun(base, s, c) { + const g = s.g, u = g.u, P = s.P, sun = s.sunPos(); + if (sun.a <= 0) return; + const r = 40 * u, sz = r * 2.4, inks = [YELLOW, YELLOW, ORANGE, ORANGE, RED, RED, RED]; + c.save(); c.beginPath(); c.rect(0, 0, g.W, g.horizon); c.clip(); + c.globalAlpha = sun.a; + s.sprite('sun', sz, sz, sun.x, sun.y, x => { + x.save(); poly(x, ellipse(0, 0, r, r, 64)); x.clip(); + const h = r * 2 / inks.length; + inks.forEach((col, i) => { + const gap = Math.max(0, i - 2) * 0.13 * h; + x.fillStyle = rgba(mul(col, P.paper)); x.fillRect(-r, -r + i * h + gap, r * 2, h - gap + 0.5); + }); + x.restore(); + }); + c.restore(); + }, + // the light in bands of yellow and orange, like the stripes under the bar + beam(base, s, c) { + const g = s.g, P = s.P, I = s.beamI(), pw = s.lampPower(), { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || pw < 0.02) return; + const night = smooth(0.3, 0.8, s.d), al = (0.86 + 0.12 * night * I) * pw; + const fan = (b0, b1) => { const f = [[L.x, L.y]]; for (let i = 0; i <= 8; i++) { const a = lerp(b0, b1, i / 8); f.push([L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]); } return f; }; + const all = fan(a0, a1); + poly(c, all); c.fillStyle = rgba(mix(P.beam, CREAM, night * 0.3), al); c.fill(); + for (const k of [1, 3]) { poly(c, fan(lerp(a0, a1, k / 5), lerp(a0, a1, (k + 1) / 5))); c.fillStyle = rgba(ORANGE, al); c.fill(); } + c.save(); poly(c, all); c.clip(); c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + c.strokeStyle = rgba(CREAM, 0.9 * pw); waveScallops(s, c, true); + c.restore(); + const r0 = g.lh.er * 2.3; + c.beginPath(); + for (const a of [a0, a1]) brush(c, dens([[L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0], [L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]], 9), Hill.DET); + if (s.beam.len < 0.85) brush(c, dens(all.slice(1), 6), Hill.DET); + c.globalAlpha = 0.3 + 0.7 * pw; c.fillStyle = Hill.INK; c.fill(); c.globalAlpha = 1; + }, + cloud(base, s, c, cl, name, legs) { + const g = s.g, u = g.u, P = s.P, p = s.cloudXY(cl), w = cl.w, { DET, TEX } = Hill; + if (p.x < -w || p.x > g.W + w) return; + if (legs) { + // four legs, walking when it walks + const walk = s.reduced ? 0 : s.t * 5; + c.beginPath(); + [-0.24, -0.11, 0.1, 0.23].forEach((lx, i) => { + const x = p.x + lx * w * (cl.dir < 0 ? -1 : 1), sw = Math.sin(walk + i * 1.7 + (i % 2) * Math.PI) * 2.6 * u; + brush(c, [[x, p.y - 4 * u], [x + sw * 0.5, p.y + 5 * u], [x + sw, p.y + 12 * u]], DET * 1.25, i); + }); + c.fillStyle = Hill.INK; c.fill(); + } + s.sprite(name + (legs && cl.dir < 0 ? 'L' : ''), w * 1.5, w * 0.9, p.x, p.y - w * 0.12, x => { + // round puffs standing on a flat base + const R = rng(cl.seed), n = 3 + ((R() * 2) | 0), by = w * 0.12, puffs = [], top = []; + for (let i = 0; i < n; i++) { const fx = (i + 0.5) / n; puffs.push([-w / 2 + fx * w, w * (0.2 + R() * 0.08) * (1.25 - Math.abs(fx - 0.5) * 0.9)]); } + for (let i = 0; i <= 72; i++) { + const px = lerp(-w * 0.62, w * 0.62, i / 72); + let py = by; + for (const [bx, r] of puffs) if (Math.abs(px - bx) < r) py = Math.min(py, by - Math.sqrt(r * r - (px - bx) ** 2)); + if (py < by) top.push([px, py]); + } + const pts = dens([...top, [top.at(-1)[0], by], [top[0][0], by]], 4, true); + Hill.print(x, pts, P.cloud); + // dots along its underside + x.save(); poly(x, pts, true, Hill.REGX, Hill.REGY); x.clip(); x.fillStyle = DOTS.light; x.fillRect(-w, by - w * 0.09, w * 2, w * 0.1); x.restore(); + x.beginPath(); Hill.loop(x, pts, DET, cl.seed % 13); + // a curl inside each puff, where it catches the light + puffs.forEach(([bx, r], i) => brush(x, ellipse(bx, by, r * 0.66, r * 0.66, 10, Math.PI * 1.18, Math.PI * 1.48), TEX, i)); + x.fillStyle = Hill.INK; x.fill(); + }); + }, + // the shady flank of the hill in dots, middling on it and light in a band round it that thins out into the lit + // side, in place of the felt pen's hatching + hill(base, s, c) { + base(s, c); + const g = s.g, { W, H, u } = g, T = g.lh, sh = g.ridge.filter(([x]) => x < T.x - T.wB * 2), n = sh.length, x0 = g.shore?.[0][0]; + // f times as wide as the felt pen's: a cliff band above the shore, or the side turning away from the light + const flank = f => g.shore + ? [...g.shore, ...g.shore.slice().reverse().map(([x, y]) => [x + 4 * u, y - (8 + (x - x0) / W * 26) * u * f])] + : [...sh, ...sh.map(([x, y], i) => { const k = smooth(0, 0.35, (n - 1 - i) / n) * f; return [x + (34 + (H - y) * 0.12) * u * k, y + (16 + (H - y) * 0.05) * u * k]; }).reverse()]; + c.save(); poly(c, flank(1), true, Hill.REGX, Hill.REGY); c.rect(0, 0, W, H); c.clip('evenodd'); + shade(c, flank(2.2), DOTS.light); + c.restore(); + shade(c, flank(1), DOTS.mid); + }, + // the tower's shady side in dots, in place of the felt pen's hatching + tower(base, s, c) { + base(s, c); + const T = s.g.lh, half = k => lerp(T.wB, T.wT, k) / 2, Sp = [], Rp = []; + for (let i = 0; i <= 20; i++) { const k = i / 20; Sp.push([T.x + half(k) * 0.3, T.base - T.h * k]); Rp.push([T.x + half(k), T.base - T.h * k]); } + shade(c, [...Sp, ...Rp.reverse()], DOTS.mid); + }, + // grass along the ridge in fat blades, leaning with the wind and away from the pointer + tufts(base, s, c) { + const g = s.g, u = g.u; + c.beginPath(); + for (const b of g.tufts) { + let a = s.reduced ? 0.12 : Math.sin(s.t * 1.6 + b.p) * 0.14 * (0.6 + s.windAmb) + s.windTotal * 0.36; + if (s.ptr.on && !s.reduced) { + const dx = b.x - s.ptr.x, dy = b.y - s.ptr.y, r = 95 * u, d2 = dx * dx + dy * dy; + if (d2 < r * r) a += Math.sign(dx || 1) * 1.05 * (1 - Math.sqrt(d2) / r); + } + a = clamp(a, -1.3, 1.3); + for (let k = 0; k < b.n; k++) { + const da = (k - (b.n - 1) / 2) * 0.5, l = b.l * (k === 1 ? 1.1 : 0.8), x = b.x + (k - (b.n - 1) / 2) * 2.6 * u, y = b.y + 1.5 * u; + const tx = x + Math.sin(a + da) * l, ty = y - Math.cos(a + da) * l, w = 2 * g.pw; + c.moveTo(x - w, y); c.quadraticCurveTo(lerp(x, tx, 0.5) - w * 0.5, lerp(y, ty, 0.55), tx, ty); c.quadraticCurveTo(lerp(x, tx, 0.5) + w * 0.5, lerp(y, ty, 0.55), x + w, y); c.closePath(); + } + } + c.fillStyle = Hill.INK; c.fill(); + }, + // the shadow of the eaves across the top of the wall + cottage(base, s, c) { + base(s, c); + const k = s.g.cot, x0 = k.x - k.w / 2, y0 = k.base - k.h; + shade(c, [[x0, y0], [x0 + k.w, y0], [x0 + k.w, y0 + k.h * 0.18], [x0, y0 + k.h * 0.18]], DOTS.mid); + }, + sheep(base, c, u, wool, o, t) { base(c, u, wool, o, t); belly(c, 0, -16 * u, 14 * u, 9.5 * u, -14 * u); }, + cow(base, c, u, white, P, o, t) { base(c, u, white, P, o, t); belly(c, -0.75 * u, -18 * u, 17.5 * u, 8 * u, -15 * u); }, + duck(base, c, u, yellow, P, o, t) { base(c, u, yellow, P, o, t); belly(c, -1.5 * u, -5.5 * u, 12 * u, 5.5 * u, -5 * u); }, + }, +}); +})(); diff --git a/site/styles/swiss.js b/site/styles/swiss.js new file mode 100644 index 00000000..14c9e38d --- /dev/null +++ b/site/styles/swiss.js @@ -0,0 +1,317 @@ +/* Lighthouse Hill in a Swiss travel poster, for Léman (see the styles in scene.js). + A lithograph of the 1930s: no outlines, flat planes lit from the upper left, each form with its lit side and its + shady side, under an airbrushed sky. Lake Geneva in flat bands with a few reflections, the Alps across the water, + and the lettering in bold condensed capitals. */ +(() => { +'use strict'; +const { TAU, clamp, lerp, smooth, hash, rng, hex, mix, mul, rgba, stops, poly, ellipse, dens, dot, cloudPts, view, afloat, beamPoly, THEMES, TEX, DET, DIVE_GAP, SHORE, FAR } = Hill; +const print = Hill.felt.print; + +// the letters: Helvetica's bold condensed where the system has it +document.fonts.add(new FontFace('Leman Poster', 'local("HelveticaNeue-CondensedBold"), local("AvenirNextCondensed-Bold"), local("ArialNarrow-Bold")')); + +/* The hours: teal by day, a warm horizon at dusk, a deep teal-blue night with the snow pale under the moon */ +const pal = stops([ + [0, { sky: '#3F9DB0', sky2: '#DCEBE8', sea: '#0E7C7B', sea2: '#8ED8CC', foam: '#EEF6F2', hill: '#9DC36A', hill2: '#4E8A6A', ink: '#12343B', paper: '#FBF4E4', beam: '#FFE08A', cloud: '#FBF7EE', far: '#8DB9BD', glass: '#BFE3DF' }], + [0.2, { sky: '#4F98AE', sky2: '#F1E2C6', sea: '#147679', sea2: '#9ADACB', foam: '#F4EEE2', hill: '#96BB64', hill2: '#4A8467', ink: '#12343B', paper: '#FBEFD9', beam: '#FFDD80', cloud: '#FAF0E0', far: '#93B3BA', glass: '#F1E2C6' }], + [0.44, { sky: '#2F7189', sky2: '#F4B27E', sea: '#1A5E69', sea2: '#F0B184', foam: '#F7DCC2', hill: '#729B5C', hill2: '#3D6B5D', ink: '#12343B', paper: '#F8E1C6', beam: '#FFD66B', cloud: '#F4CDAE', far: '#8C8E9E', glass: '#FFC98A' }], + [0.7, { sky: '#174759', sky2: '#4F7F86', sea: '#123C47', sea2: '#5E9C98', foam: '#B6D2CC', hill: '#3E6757', hill2: '#274A44', ink: '#0B262C', paper: '#D9E5E1', beam: '#FFE07A', cloud: '#6E9399', far: '#3A6670', glass: '#FFE07A' }], + [1, { sky: '#0A2430', sky2: '#1C4B57', sea: '#0A2129', sea2: '#4E9A95', foam: '#9FC9C2', hill: '#264B43', hill2: '#173630', ink: '#071B20', paper: '#CFE2DD', beam: '#FFE58A', cloud: '#2F5862', far: '#2B5561', glass: '#FFE58A' }], +]); + +// the shady side of a colour: toward the deep teal of the ink, as the printer overprints it +const dim = (P, col, k = 0.3) => mix(col, P.ink, k); + +/* No outlines, and the lines that are features are thin flat shapes, one width all along */ +function flat(c, pts, w, sd, o = {}) { + if (o.outline) return; + const n = pts.length, hw = w * Hill.PW * 0.5, l = [], r = []; + for (let i = 0; i < n; i++) { + const a = pts[i ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : i], tl = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const nx = -(b[1] - a[1]) / tl * hw, ny = (b[0] - a[0]) / tl * hw; + l.push([pts[i][0] + nx, pts[i][1] + ny]); r.push([pts[i][0] - nx, pts[i][1] - ny]); + } + c.moveTo(l[0][0], l[0][1]); + for (const p of l) c.lineTo(p[0], p[1]); + for (let i = n - 1; i >= 0; i--) c.lineTo(r[i][0], r[i][1]); + c.closePath(); +} + +/* The fill, flat; a rounded form (more than a few corners: a quad is a plane already) also gets its shady side, the + ink overprinted where the same form a little up and to the left (on the screen) wouldn't cover it */ +function plane(c, pts, col) { + poly(c, pts); c.fillStyle = typeof col === 'string' ? col : rgba(col); c.fill(); + if (pts.length < 9) return; + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const [x, y] of pts) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); } + const d = Math.min(x1 - x0, y1 - y0) * 0.16, dx = d * 0.5 * Math.sign(c.getTransform().a); + c.save(); c.clip(); + c.beginPath(); c.rect(x0, y0, x1 - x0, y1 - y0); c.moveTo(pts[0][0] - dx, pts[0][1] - d); + for (const [x, y] of pts) c.lineTo(x - dx, y - d); + c.globalAlpha *= 0.2; c.fillStyle = Hill.INK; c.fill('evenodd'); + c.restore(); +} + +/* The Alps across the lake: peaks of the range, the tallest by the lake's end, and none where the sun goes down into + the water */ +function alps(g) { + const { W, u, horizon: hz, top } = g, R = rng(5), sky = hz - top, r = 38 * u, a = g.sun.x - r * 1.25, b = g.sun.x + r * 1.25, peaks = []; + for (let x = -60 * u; x < W + 60 * u; x += (45 + R() * 80) * u) { + const h = sky * (0.075 + R() * 0.09) * (1 + 0.6 * Math.exp(-(((x - a) / (160 * u)) ** 2))); + let wl = h * (1.3 + R() * 0.8), wr = h * (1.4 + R() * 0.9); + if (x > a - 8 * u && x < b + 8 * u) continue; + if (x < a) wr = Math.min(wr, a - x); else wl = Math.min(wl, x - b); + peaks.push({ x, h, wl, wr, sd: R() }); + } + return { peaks: peaks.sort((p, q) => q.h - p.h), a, b }; +} +// the sky above them, for what rises and sets behind them +function behindAlps(s, c) { + const g = s.g, hz = g.horizon, { peaks } = alps(g), line = [[g.W, 0], [0, 0]]; + for (let x = 0; x < g.W + 3; x += 3) { + let y = hz; + for (const p of peaks) y = Math.min(y, hz - p.h + (x < p.x ? (p.x - x) / p.wl : (x - p.x) / p.wr) * p.h); + line.push([x, y]); + } + poly(c, line); c.clip(); +} + +/* The lake's reflections: short flat strokes in rows to the horizon, the rows closer together and sparser the farther + off, coming toward you as you dive (the rows are the felt pen's scallops': see waveScallops) */ +const ROW = 0.02; +function glints(s, c, col) { + const g = s.g, { W, H, u } = g, hz = g.horizon, sea = H - hz, go = s.dive * DIVE_GAP / SHORE; + c.fillStyle = col; + for (let k = Math.ceil((1 + go) / ROW); ;) { + const rel = 1 / (k * ROW - go); + if (rel < 0.012) break; + const gap = 3.2 * sea * rel ** 0.375 / g.rows, dy = sea * ROW * rel * rel, step = 2 ** Math.max(0, Math.ceil(Math.log2(gap / dy))); + if (k % step) { k += step - k % step; continue; } + const y = hz + sea * rel, t = (0.9 + 2.6 * rel) * u, l = (12 + 90 * rel) * u; + c.globalAlpha = k % (step * 2) ? clamp(dy * step / gap - 1) : 1; + for (let j = 0; j < 4; j++) { + const f = hash(k * 7.31 + j * 1.93); + if (f < 0.35) continue; + c.fillRect(hash(k * 3.17 + j * 5.3) * (W + l) - l, y, l * (0.4 + f), t); + } + k += step; + } + c.globalAlpha = 1; +} + +Hill.style('leman', { + pal, + C: { + wool: hex('#FBF4E4'), yolk: hex('#F2B33D'), duck: hex('#F2C230'), beak: hex('#E07A2F'), pink: hex('#EFA3A8'), + monster: hex('#E98FB0'), chimney: hex('#B9573A'), roof: hex('#C8643B'), door: hex('#0E7C7B'), navy: hex('#12343B'), + tile: hex('#12343B'), moon: hex('#F6E9BE'), sprout: hex('#5E9A45'), + huts: ['#C8643B', '#0E7C7B', '#E8B04B', '#5CC5B8'].map(hex), + }, + ribbon: flat, print: plane, pen: 0.9, boil: 0, reg: [0, 0], + // poster lettering is in capitals + hand: '"Leman Poster", "Helvetica Neue", "Avenir Next Condensed", "Arial Narrow", sans-serif', caps: true, + draw: { + // an airbrushed sky down to the horizon, and the lake in flat bands, lightest far off where it takes the sky + sky(base, s, c) { + const g = s.g, P = s.P, hz = g.horizon, sea = g.H - hz, air = c.createLinearGradient(0, g.top, 0, hz); + air.addColorStop(0, rgba(P.sky)); air.addColorStop(0.55, rgba(mix(P.sky, P.sky2, 0.35))); air.addColorStop(1, rgba(P.sky2)); + c.fillStyle = air; c.fillRect(0, 0, g.W, hz + 2); + for (const [f, k] of [[1, 0], [0.3, 0.08], [0.13, 0.18], [0.055, 0.3], [0.02, 0.46]]) { c.fillStyle = rgba(mix(P.sea, P.sky2, k)); c.fillRect(0, hz, g.W, sea * f); } + // the snow of the Alps upside down in the far water, in broken strokes + c.fillStyle = rgba(mix(P.paper, P.sea, 0.5), 0.8); + for (const p of alps(g).peaks) for (let i = 0; i < 4; i++) { + const d = p.h * 0.45 * (0.55 + i * 0.12), w = (p.wl + p.wr) * (1 - d / (p.h * 0.45)), x = p.x - w / 2 + (p.wr - p.wl) * 0.2, cut = hash(p.sd * 9 + i) * w; + c.fillRect(x, hz + d, cut - 1.5 * g.u, p.h * 0.035); c.fillRect(x + cut + 1.5 * g.u, hz + d, w - cut - 1.5 * g.u, p.h * 0.035); + } + }, + // the Alps across the water, each peak a lit face and a shady one, snow down to a ragged line; under them, the far + // shore, low and dark, open where the lake goes on to the horizon + band(base, s, c) { + const g = s.g, { W, u } = g, P = s.P, hz = g.horizon, { peaks, a, b } = alps(g); + const snow = [P.paper, mix(P.paper, P.sky, 0.42)], rock = [mix(P.far, P.sky2, 0.3), dim(P, P.far, 0.38)]; + for (const p of peaks) { + const top = [p.x, hz - p.h], spine = [p.x + p.h * 0.22, hz + 1], faces = [[top, [p.x - p.wl, hz + 1], spine], [top, spine, [p.x + p.wr, hz + 1]]]; + const R = rng(p.sd * 1e6), cap = [[p.x - p.wl, hz - p.h * 2]]; + for (let i = 0; i <= 12; i++) cap.push([lerp(p.x - p.wl, p.x + p.wr, i / 12), hz - p.h * (i % 2 ? 0.62 + R() * 0.1 : 0.36 + R() * 0.16)]); + cap.push([p.x + p.wr, hz - p.h * 2]); + faces.forEach((f, i) => { print(c, f, rock[i]); c.save(); poly(c, f); c.clip(); print(c, cap, snow[i]); c.restore(); }); + } + const shore = (x0, x1) => { + const pts = [[x0, hz + 1]]; + for (let x = x0; x <= x1; x += 8 * u) pts.push([x, hz - (2.5 + 1.8 * Math.sin(x * 0.013) + 1.2 * Math.sin(x * 0.041)) * u * smooth(0, 30 * u, Math.min(x - x0, x1 - x))]); + pts.push([x1, hz + 1]); + print(c, pts, dim(P, P.far, 0.6)); + }; + shore(-10, a - 6 * u); shore(b + 6 * u, W + 10); + }, + stars(base, s, c) { c.save(); behindAlps(s, c); base(s, c); c.restore(); }, + sun(base, s, c) { c.save(); behindAlps(s, c); base(s, c); c.restore(); }, + moon(base, s, c) { c.save(); behindAlps(s, c); base(s, c); c.restore(); }, + // the lamp's halo at night: flat rings of light round the lantern, fainter outward + halo(base, s, c) { + const P = s.P, L = s.g.lamp, er = s.g.lh.er, f = s.beam.flash, k = Math.max(smooth(0.35, 0.85, s.d) * s.beamI(), f * 0.9) * s.lampOn() * s.lampPower(); + if (k < 0.02) return; + for (const [r, a] of [[3.6 + f * 1.4, 0.14], [2.7 + f, 0.2], [1.75, 0.75]]) dot(c, L.x, L.y, er * r, rgba(mix(P.beam, hex('#FFFFFF'), 0.25), clamp(k * a * 1.4))); + }, + waves(base, s, c) { glints(s, c, rgba(s.P.sea2, 0.75)); }, + // an island's reflection on the lake (or the headland's): under its shore, its hill and its tower upside down in + // broken strokes, and the lamp's light on the water; then rings where things land + ripples(base, s, c) { + const g = s.g, u = g.u, P = s.P, v = s.cur.view; + if (g.shore) { + const T = g.lh, L = g.lamp, sl = g.shore, step = 3.2 * u / v.s, K = 0.8, xs = []; + view(s, c, v); c.globalAlpha = smooth(FAR, FAR * 1.6, v.s); + for (let x = sl[0][0]; x < sl.at(-1)[0]; x += step * 1.6) { const yw = s.shoreY(x); xs.push([x, yw, yw + (yw - s.ridgeY(x)) * K]); } + c.fillStyle = rgba(dim(P, P.sea, 0.35)); + for (let y = Math.min(...xs.map(p => p[1])), i = 0; ; y += step, i++) { + let x0 = null, any = false; + for (const [x, yw, yd] of xs) { + const on = y > yw && y < yd && hash(i * 13.1 + x * 0.071) > 0.12; + any ||= y < yd; + if (on && x0 == null) x0 = x; + if (!on && x0 != null) { c.fillRect(x0, y, x - x0, step * 0.55); x0 = null; } + } + if (x0 != null) c.fillRect(x0, y, xs.at(-1)[0] - x0, step * 0.55); + if (!any) break; + } + // the tower, from its foot down: white and the theme's stripes, broken across + const th = THEMES[g.theme || s.theme], yw = s.shoreY(T.x); + for (let y = yw + (yw - T.base) * K, i = 0; y < yw + (yw - T.top) * K; y += step, i++) { + const k = (T.base - (yw - (y - yw) / K)) / T.h, hw = lerp(T.wB, T.wT, k) / 2, cut = (hash(i * 3.7) - 0.5) * hw; + const j = Math.floor(k * 6); + c.fillStyle = rgba(mix(j % 2 ? hex(th.stripes[(j >> 1) % th.stripes.length]) : P.paper, P.sea, 0.4)); + c.fillRect(T.x - hw, y, hw + cut - step * 0.4, step * 0.55); c.fillRect(T.x + cut + step * 0.4, y, hw - cut - step * 0.4, step * 0.55); + } + // the lamp: a glade of light strokes where it shows in the water + const a = s.lampPower() * s.lampOn() * (0.3 + 0.7 * smooth(0.3, 0.8, s.d)), ly = yw + (yw - L.y) * K, er = T.er; + c.fillStyle = rgba(P.beam, a); + if (a >= 0.02) for (let j = -4; j <= 6; j++) { + const w = er * (2.4 - Math.abs(j) * 0.3) * (0.7 + 0.6 * hash(j + Math.floor(s.t * 3))), y = ly + j * er * 0.42; + c.fillRect(L.x - w / 2, y, w, step * 0.6); + } + c.globalAlpha = 1; + } + for (const r of s.ripples) { + if (!afloat(s, c, r.y)) continue; + const age = s.t - r.t0, rx = 8 * u + age * 70 * u; + c.beginPath(); c.ellipse(r.x, r.y, rx, rx * 0.3, 0, 0, TAU); c.lineWidth = 1.6 * g.pw; c.strokeStyle = rgba(P.foam, clamp(1 - age / 2.2) * r.a); c.stroke(); + } + }, + // the light: a flat fan with a brighter core, and the lake's strokes under it lit + beam(base, s, c) { + const P = s.P, I = s.beamI(), pw = s.lampPower(), { L, len, a0, a1 } = beamPoly(s); + if (len < 10 || pw < 0.02) return; + const night = smooth(0.3, 0.8, s.d), col = mix(P.beam, hex('#FFF6C2'), night * 0.45); + const fan = k => { const m = (a0 + a1) / 2, out = [[L.x, L.y]]; for (let i = 0; i <= 12; i++) { const a = m + (a1 - m) * k * (i / 6 - 1); out.push([L.x + Math.cos(a) * len, L.y + Math.sin(a) * len]); } return out; }; + poly(c, fan(1)); c.fillStyle = rgba(col, (0.8 + 0.14 * night * I) * pw); c.fill(); + poly(c, fan(0.4)); c.fillStyle = rgba(mix(col, hex('#FFFFFF'), 0.45), 0.85 * pw); c.fill(); + c.save(); poly(c, fan(1)); c.clip(); c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + glints(s, c, rgba(mix(col, hex('#FFFFFF'), 0.5), 0.95 * pw)); + c.restore(); + }, + // a cloud lit from above on the left, its underside in shade + cloud(base, s, c, cl, name, legs) { + const g = s.g, u = g.u, P = s.P, p = s.cloudXY(cl), w = cl.w; + if (p.x < -w || p.x > g.W + w) return; + if (legs) { + const walk = s.reduced ? 0 : s.t * 5, feet = []; + c.beginPath(); + [-0.24, -0.11, 0.1, 0.23].forEach((lx, i) => { + const x = p.x + lx * w * (cl.dir < 0 ? -1 : 1), sw = Math.sin(walk + i * 1.7 + (i % 2) * Math.PI) * 2.6 * u; + flat(c, [[x, p.y - 4 * u], [x + sw * 0.5, p.y + 5 * u], [x + sw, p.y + 12 * u]], DET * 1.25); + feet.push([x + sw, p.y + 13 * u]); + }); + c.fillStyle = Hill.INK; c.fill(); + for (const [fx, fy] of feet) { c.beginPath(); c.ellipse(fx + 0.6 * u, fy, 2.6 * u, 1.9 * u, 0, 0, TAU); c.fill(); } + } + s.sprite(name + (legs && cl.dir < 0 ? 'L' : ''), w * 1.3, w * 0.75, p.x, p.y - w * 0.12, x => { + const pts = dens(cloudPts(0, w * 0.12, w, cl.seed), 5, true); + plane(x, pts, P.cloud); + if (legs) { + const f = cl.dir < 0 ? -1 : 1; + x.beginPath(); flat(x, [[-f * w * 0.5, w * 0.02], [-f * w * 0.57, -w * 0.03], [-f * w * 0.6, w * 0.03]], TEX * 1.3); x.fillStyle = Hill.INK; x.fill(); + } + }); + }, + // the hill: the slope down to the water lit, past the top the ground turning away in shade, the cliff over the + // shore in shade, and the tower's and the cottage's shadows cast across the grass to the right + hill(base, s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, k = g.cot, rd = g.ridge, top = rd.reduce((a, p) => (p[1] < a[1] ? p : a)); + print(c, g.hillPoly, P.hill); + c.save(); poly(c, g.hillPoly); c.clip(); + for (const [side, col, f] of [[-1, mix(P.hill, P.paper, 0.2), 0.55], [1, P.hill2, 0.4]]) { + const sh = rd.filter(([x]) => side * (x - top[0]) > T.wB * 2); + print(c, [...sh, ...sh.map(([x, y]) => [lerp(x, top[0], f), y + Math.abs(top[0] - x) * 0.12]).reverse()], col); + } + if (g.shore) { const sl = g.shore, x0 = sl[0][0]; print(c, [...sl, ...sl.slice().reverse().map(([x, y]) => [x + 4 * u, y - (8 + (x - x0) / g.W * 26) * u])], P.hill2); } + const cast = (x0, x1, y, l, end) => print(c, [[x0, y], [x1, y], [x1 + l * 0.8, y + l * 0.5], [x1 + l * 0.8 - end, y + l * 0.5]], dim(P, P.hill2, 0.12)); + cast(T.x - T.wB / 2, T.x + T.wB / 2, T.base - 2 * u, T.h * 0.32, T.wT); + cast(k.x - k.w / 2, k.x + k.w / 2, k.base - 3 * u, k.h * 1.1, k.w * 0.4); + c.restore(); + // the water's edge, a thin line of foam + c.beginPath(); + flat(c, g.shore ? g.shore.map(([x, y]) => [x, y + 1.5 * u]) : rd.filter(p => p[1] > g.horizon + 3 && p[0] < T.x).map(([x, y]) => [x - 2 * u, y]), 2.2); + c.fillStyle = rgba(P.foam); c.fill(); + }, + // the cottage: a lit wall with the eaves' shadow under the roof, a roof and a chimney lit on the left + cottage(base, s, c) { + const g = s.g, u = g.u, P = s.P, k = g.cot, w = k.w, h = k.h, x0 = k.x - w / 2, y0 = k.base - h; + const paper = mul(hex('#FFFFFF'), P.paper), night = smooth(0.45, 0.75, s.d), chim = Hill.C.chimney, roof = Hill.C.roof; + const chx = x0 + w * 0.68, chw = w * 0.15, cht = y0 - h * 1.45; + print(c, [[chx, y0 - h * 0.2], [chx, cht], [chx + chw, cht], [chx + chw, y0 - h * 0.2]], chim); + print(c, [[chx + chw * 0.6, y0 - h * 0.2], [chx + chw * 0.6, cht], [chx + chw, cht], [chx + chw, y0 - h * 0.2]], dim(P, chim)); + print(c, [[chx - 1.5 * u, cht + 2 * u], [chx - 1.5 * u, cht - 1.5 * u], [chx + chw + 1.5 * u, cht - 1.5 * u], [chx + chw + 1.5 * u, cht + 2 * u]], dim(P, chim, 0.45)); + print(c, [[x0, k.base], [x0, y0], [x0 + w, y0], [x0 + w, k.base]], paper); + print(c, [[x0, y0], [x0 + w, y0], [x0 + w, y0 + h * 0.13], [x0, y0 + h * 0.07]], dim(P, paper, 0.22)); + const L = [x0 - w * 0.12, y0 + 1 * u], A = [x0 + w * 0.5, y0 - h * 0.78], Rt = [x0 + w * 1.12, y0 + 1 * u], M = [x0 + w * 0.5, y0 + 1 * u]; + print(c, [L, A, M], roof); print(c, [A, Rt, M], dim(P, roof)); + // the window: four panes, lit at night, the bars between them in the wall's shade + const wx = x0 + w * 0.14, wy = y0 + h * 0.24, ww = w * 0.27, wh = h * 0.36, bar = 1.4 * u; + print(c, [[wx, wy], [wx + ww, wy], [wx + ww, wy + wh], [wx, wy + wh]], night > 0.5 ? P.beam : P.glass); + c.fillStyle = rgba(dim(P, paper, 0.45)); + c.fillRect(wx + ww / 2 - bar / 2, wy, bar, wh); c.fillRect(wx, wy + wh / 2 - bar / 2, ww, bar); c.fillRect(wx - bar, wy + wh, ww + bar * 2, bar * 1.2); + // the door (it opens for the keeper: see the states) + const dx = x0 + w * 0.56, dw = w * 0.24, dh = h * 0.66; + k.door = { x: dx, y: k.base - dh, w: dw, h: dh }; + print(c, [[dx, k.base], [dx, k.base - dh], [dx + dw, k.base - dh], [dx + dw, k.base]], mul(Hill.C.door, P.paper)); + dot(c, dx + dw * 0.78, k.base - dh * 0.48, 1.3 * u + 0.4, Hill.INK); + }, + // the tower: white with the theme's stripes, its right side in shade; dark windows, lit at night + tower(base, s, c) { + const g = s.g, u = g.u, P = s.P, T = g.lh, th = THEMES[g.theme || s.theme], paper = mul(hex('#FFFFFF'), P.paper); + const half = k => lerp(T.wB, T.wT, k) / 2, yAt = k => T.base - T.h * k, bands = 6, sag = T.wB * 0.09; + const side = f => { const out = []; for (let i = 0; i <= 20; i++) out.push([T.x + f * half(i / 20), yAt(i / 20)]); return out; }; + print(c, [...side(-1), ...side(1).reverse()], paper); + const edge = k => { const hw = half(k), out = []; for (let i = 0; i <= 12; i++) { const f = i / 12 * 2 - 1; out.push([T.x + f * hw, yAt(k) - sag * (1 - f * f)]); } return out; }; + let n = 0; + for (let j = 1; j < bands; j += 2) { + const hi = j + 1 >= bands ? [[T.x + half(1), yAt(1)], [T.x - half(1), yAt(1)]] : edge((j + 1) / bands).reverse(); + print(c, [...edge(j / bands), ...hi], mul(hex(th.stripes[n++ % th.stripes.length]), P.paper)); + } + print(c, [...side(0.28), ...side(1).reverse()], rgba(P.ink, 0.3)); + const night = smooth(0.45, 0.75, s.d), glass = night > 0.5 ? P.beam : P.ink; + for (const k of [0.25, 0.58]) { const y = yAt(k), w = T.wB * 0.22, h = T.er * 0.75; print(c, [[T.x - w / 2, y - h / 2], [T.x + w / 2, y - h / 2], [T.x + w / 2, y + h / 2], [T.x - w / 2, y + h / 2]], glass); } + const dw = T.wB * 0.32, dh = T.wB * 0.5; + print(c, [[T.x - dw / 2, T.base], ...ellipse(T.x, T.base - dh + dw / 2, dw / 2, dw / 2, 10, Math.PI, TAU), [T.x + dw / 2, T.base]], P.ink); + // the gallery: a dark deck the eye sits on, lit along its top + const gw = T.wT * 0.95, gy = T.top; + print(c, [[T.x - gw, gy + 1 * u], [T.x - gw * 1.05, gy - T.gal * 0.42], [T.x + gw * 1.05, gy - T.gal * 0.42], [T.x + gw, gy + 1 * u]], P.ink); + print(c, [[T.x - gw * 1.05, gy - T.gal * 0.42], [T.x + gw * 1.05, gy - T.gal * 0.42], [T.x + gw * 1.04, gy - T.gal * 0.3], [T.x - gw * 1.04, gy - T.gal * 0.3]], dim(P, paper, 0.5)); + }, + // grass in a darker green than the hill, not in ink + tufts(base, s, c) { base(s, c); c.fillStyle = rgba(dim(s.P, s.P.hill2, 0.25)); c.fill(); }, + // smoke in round flat puffs, lit on the left + smoke(base, s, c) { + if (s.reduced) return; + const g = s.g, u = g.u, P = s.P, k = g.cot, x0 = k.x - k.w / 2 + k.w * 0.755, y0 = k.base - k.h * 2.5; + for (let i = 0; i < 3; i++) { + const a = (s.t * 0.22 + i / 3) % 1, x = x0 + s.windTotal * 50 * u * a + Math.sin(a * 6 + i) * 3 * u, y = y0 - 6 * u - a * 46 * u, r = (2.5 + a * 6) * u; + c.globalAlpha = 1 - smooth(0.6, 1, a); + dot(c, x + r * 0.2, y + r * 0.15, r, dim(P, P.cloud, 0.25)); dot(c, x - r * 0.1, y - r * 0.1, r * 0.82, P.cloud); + } + c.globalAlpha = 1; + }, + }, + +}); +})(); diff --git a/site/styles/vector.js b/site/styles/vector.js new file mode 100644 index 00000000..f8f2ca0f --- /dev/null +++ b/site/styles/vector.js @@ -0,0 +1,374 @@ +/* Lighthouse Hill on a vector screen, for Phosphor (see the styles in scene.js): by night an amber vector monitor, one + thin glowing beam for every line, the shapes black inside so they hide what's behind them, scanlines over it all + and the light a radar's sweep; by day the same lines on a pen plotter, brown and amber ink on cream, hatched for + shade. The screen comes on with the hour. */ +(() => { +const { lerp, smooth, hex, rgba, angDiff, poly, ellipse, curve, dens, edges, beamPoly, waveScallops, hatGeom, head, joyAt, napEye, dot, shape, THEMES, TAU, DET, TEX } = Hill; + +// what hides what's behind a shape: the screen, or the paper, at this hour (each frame, and for each island) +let screen = '#0A0703'; +const glowAt = d => smooth(0.45, 0.9, d); + +/* The beam (or the pen) at one width all along, round at both ends: the outline, for the caller to fill */ +function line(c, pts) { + const n = pts.length, h = 0.62 * Hill.PW; + if (n < 2) return; + const A = [], B = []; + for (let i = 0; i < n; i++) { + const a = pts[i > 0 ? i - 1 : 0], b = pts[i < n - 1 ? i + 1 : n - 1], l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const tx = (b[0] - a[0]) / l * h, ty = (b[1] - a[1]) / l * h, [x, y] = pts[i]; + A.push([x + ty, y - tx]); B.push([x - ty, y + tx]); + } + const e0 = Math.atan2(pts[1][1] - pts[0][1], pts[1][0] - pts[0][0]), e1 = Math.atan2(pts[n - 1][1] - pts[n - 2][1], pts[n - 1][0] - pts[n - 2][0]); + c.moveTo(A[0][0], A[0][1]); + for (let i = 1; i < n; i++) c.lineTo(A[i][0], A[i][1]); + c.arc(pts[n - 1][0], pts[n - 1][1], h, e1 - Math.PI / 2, e1 + Math.PI / 2); + for (let i = n - 1; i >= 0; i--) c.lineTo(B[i][0], B[i][1]); + c.arc(pts[0][0], pts[0][1], h, e0 + Math.PI / 2, e0 + Math.PI * 1.5); + c.closePath(); +} +/* A shape is only its outline: inside, the screen (or the paper) */ +function blank(c, pts) { poly(c, pts); c.fillStyle = screen; c.fill(); } + +/* Parallel strokes across the path on the canvas, `gap` apart and tilted by `a`, inside `box`: the plotter's shading, + and the screen's */ +function hatch(c, box, gap, a, col) { + const [x0, y0, x1, y1] = box, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2, r = Math.hypot(x1 - x0, y1 - y0) / 2, ca = Math.cos(a), sa = Math.sin(a); + c.save(); c.clip(); c.beginPath(); + for (let k = -r; k <= r; k += gap) { c.moveTo(cx - ca * r - sa * k, cy - sa * r + ca * k); c.lineTo(cx + ca * r - sa * k, cy + sa * r + ca * k); } + c.strokeStyle = col; c.lineWidth = 0.9 * Hill.PW; c.stroke(); + c.restore(); +} + +/* The light: lines out from the lamp across its fan, brightest on the edge that leads as it turns; behind it, at + night, the lines it drew a moment ago, fading as the phosphor lets them go */ +const trails = new WeakMap(); +function fan(s, c, alpha) { + const g = s.g, P = s.P, { L, len, a0, a1 } = beamPoly(s), glow = glowAt(s.d), r0 = g.lh.er * 2.3, tr = trails.get(s.beam) || []; + const lead = tr.length > 1 && angDiff(s.beam.a, tr[0].a) < 0 ? 0 : 1; + const ray = a => { c.moveTo(L.x + Math.cos(a) * r0, L.y + Math.sin(a) * r0); c.lineTo(L.x + Math.cos(a) * len, L.y + Math.sin(a) * len); }; + // at night the lines glow: the same lines under them, wide and faint + c.strokeStyle = rgba(P.beam); c.lineWidth = 6 * Hill.PW; c.globalAlpha = alpha * glow * 0.12; + c.beginPath(); for (let i = 0; i <= 8; i++) ray(lerp(a0, a1, i / 8)); c.stroke(); + c.lineWidth = 1.1 * Hill.PW; + for (const p of tr) { + const k = 1 - (s.t - p.t) / 0.7; + if (k <= 0 || p.a === s.beam.a) continue; + c.globalAlpha = alpha * glow * k * k * 0.6; + c.beginPath(); ray(p.a + (lead ? g.beamHalf : -g.beamHalf)); c.stroke(); + } + for (let i = 0; i <= 8; i++) { + const f = i / 8, w = lead ? f : 1 - f; + c.globalAlpha = alpha * lerp(0.6, 0.12 + 0.88 * w ** 2.5, glow); + c.beginPath(); ray(lerp(a0, a1, f)); c.stroke(); + } + c.globalAlpha = alpha; +} + +/* The hours: paper by day, a warmer paper at dusk, then the screen comes on */ +const pal = Hill.stops([ + [0, { sky: '#F6F1E6', sky2: '#D8C49C', sea: '#F6F1E6', sea2: '#A86400', foam: '#A86400', hill: '#F6F1E6', hill2: '#F6F1E6', ink: '#5C3A00', paper: '#A86400', beam: '#A86400', cloud: '#F6F1E6', far: '#D8C49C', glass: '#F6F1E6' }], + [0.2, { sky: '#F2EAD9', sky2: '#D2BB8F', sea: '#F2EAD9', sea2: '#A86400', foam: '#A86400', hill: '#F2EAD9', hill2: '#F2EAD9', ink: '#5C3A00', paper: '#A86400', beam: '#A86400', cloud: '#F2EAD9', far: '#D2BB8F', glass: '#F2EAD9' }], + [0.42, { sky: '#E7D5B0', sky2: '#C4A06A', sea: '#E7D5B0', sea2: '#9A5800', foam: '#9A5800', hill: '#E7D5B0', hill2: '#E7D5B0', ink: '#563300', paper: '#9A5800', beam: '#9A5800', cloud: '#E7D5B0', far: '#C4A06A', glass: '#E7D5B0' }], + [0.58, { sky: '#1A1208', sky2: '#4E340A', sea: '#1A1208', sea2: '#8A5E10', foam: '#D89500', hill: '#1A1208', hill2: '#1A1208', ink: '#E09A00', paper: '#E09A00', beam: '#FFBE3A', cloud: '#1A1208', far: '#4E340A', glass: '#1A1208' }], + [1, { sky: '#080603', sky2: '#4A3206', sea: '#080603', sea2: '#9A6A08', foam: '#FFB000', hill: '#080603', hill2: '#080603', ink: '#FFB000', paper: '#FFB000', beam: '#FFC24A', cloud: '#080603', far: '#4A3206', glass: '#080603' }], +]); + +// the scanlines +let rows = null; + +Hill.style('phosphor', { + pal, + // the colours the felt pen fills with are the pen's own ink here + C: Object.fromEntries([...['wool', 'duck', 'pink', 'monster', 'chimney', 'door', 'skin', 'navy', 'wood', 'steel', 'kraft', 'kraftLit', 'kraftDark', 'tape', 'sprout'].map(k => [k, hex('#FFFFFF')]), ...['yolk', 'beak', 'lamp'].map(k => [k, hex('#FFB000')]), ['huts', Array(4).fill(hex('#FFFFFF'))]]), + ribbon: line, print: blank, boil: 0, reg: [0, 0], + // labels and doodles in capitals, as the vector games lettered them + hand: '"Monaco", "Menlo", ui-monospace, monospace', caps: true, + before(s) { screen = rgba(s.P.sky); }, + // the glow, baked into the cached layers and sprites: each blurred and laid back over itself in light + cached(s, c) { + const k = glowAt(s.d); + if (k < 0.01) return; + const b = 6 * c.getTransform().a; + c.save(); c.setTransform(1, 0, 0, 1, 0, 0); + c.filter = `blur(${b}px)`; c.globalCompositeOperation = 'lighter'; c.globalAlpha = k; + c.drawImage(c.canvas, 0, 0); + c.restore(); + }, + after(s, c) { + const k = glowAt(s.d); + if (k < 0.01) return; + if (!rows) { + const x = document.createElement('canvas').getContext('2d'); + x.canvas.width = 1; x.canvas.height = 3; x.fillStyle = '#000'; x.fillRect(0, 2, 1, 1); + rows = x.createPattern(x.canvas, 'repeat'); + } + c.globalAlpha = k * 0.35; c.fillStyle = rows; c.fillRect(0, 0, s.g.W, s.g.H); c.globalAlpha = 1; + }, + draw: { + land(base, s, c, w) { screen = rgba(s.P.sky); base(s, c, w); }, + sky(base, s, c) { c.fillStyle = screen; c.fillRect(0, 0, s.g.W, s.g.H); }, + // the lower sky's soft wave, a dim line, and the horizon + band(base, s, c, bandTop) { + const g = s.g, { W, u } = g, by = bandTop + 20 * u, top = []; + for (let x = -30; x <= W + 30; x += 16 * u) top.push([x, by + Math.sin(x * 0.006 + 1) * 11 * u + Math.sin(x * 0.019 + 2) * 3 * u]); + c.beginPath(); line(c, top); c.fillStyle = rgba(s.P.sky2); c.fill(); + c.beginPath(); line(c, [[-10, g.horizon], [W + 10, g.horizon]]); c.fillStyle = Hill.INK; c.fill(); + }, + // stars are points, the bigger ones brighter, twinkling + stars(base, s, c) { + const g = s.g, st = smooth(0.55, 0.9, s.d); + if (st <= 0) return; + c.fillStyle = rgba(s.P.beam); + for (const p of g.stars) { + const r = (0.5 + p.s * 0.6) * g.pw; + c.globalAlpha = st * Math.min(1, p.s * 0.8) * (s.reduced ? 1 : 0.7 + 0.3 * Math.sin(s.t * 2 + p.p)); + c.fillRect(p.x - r, p.y - r, r * 2, r * 2); + } + c.globalAlpha = 1; + }, + waves(base, s, c) { c.setLineDash([4 * s.g.pw, 3 * s.g.pw]); base(s, c); c.setLineDash([]); }, + beam(base, s, c) { + const { L, len, a0, a1 } = beamPoly(s), pw = s.lampPower(), tr = trails.get(s.beam) || []; + trails.set(s.beam, tr); + if (tr.at(-1)?.t !== s.t) tr.push({ a: s.beam.a, t: s.t }); + while (s.t - tr[0].t > 0.7) tr.shift(); + if (len < 10 || pw < 0.02) return; + fan(s, c, c.globalAlpha * (0.3 + 0.7 * pw)); + // the scallops it crosses light up, dashed like the rest of the sea + c.save(); c.beginPath(); c.moveTo(L.x, L.y); c.arc(L.x, L.y, len, a0, a1); c.closePath(); c.clip(); + c.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); c.setLineDash([4 * s.g.pw, 3 * s.g.pw]); + c.strokeStyle = rgba(s.P.beam); waveScallops(s, c, true); + c.restore(); + }, + hillLight(base, s, c) { + const { len } = beamPoly(s); + if (len < 10 || Math.sin(s.beam.a) < 0.08) return; + c.save(); poly(c, s.g.hillClip); c.clip(); + fan(s, c, c.globalAlpha * 0.6 * s.lampPower()); + c.restore(); + }, + // the roof hatched, as the dark parts are + cottage(base, s, c) { + base(s, c); + const g = s.g, k = g.cot, w = k.w, h = k.h, x0 = k.x - w / 2, y0 = k.base - h; + const roof = [[x0 - w * 0.12, y0 + 1 * g.u], [x0 + w * 0.5, y0 - h * 0.78], [x0 + w * 1.12, y0 + 1 * g.u]]; + blank(c, roof); + hatch(c, [x0 - w * 0.12, y0 - h * 0.78, x0 + w * 1.12, y0], 3 * g.pw, -0.6, Hill.INK); + c.beginPath(); edges(c, roof, DET); c.fillStyle = Hill.INK; c.fill(); + }, + /* the tower: its outline, the stripes hatched in the theme's colour, the windows lit at night with a few lines */ + tower(base, s, c) { + const g = s.g, u = g.u, T = g.lh, th = THEMES[g.theme || s.theme], night = smooth(0.45, 0.75, s.d), gap = 2.6 * g.pw; + const half = k => lerp(T.wB, T.wT, k) / 2, yAt = k => T.base - T.h * k; + const Lp = [], Rp = []; + for (let i = 0; i <= 20; i++) { const k = i / 20; Lp.push([T.x - half(k), yAt(k)]); Rp.push([T.x + half(k), yAt(k)]); } + blank(c, [...Lp, ...Rp.reverse()]); + const bands = 6, sag = T.wB * 0.09; + const edge = k => { const hw = half(k), out = []; for (let i = 0; i <= 12; i++) { const f = i / 12 * 2 - 1; out.push([T.x + f * hw, yAt(k) - sag * (1 - f * f)]); } return out; }; + c.beginPath(); + for (let j = 1; j < bands; j += 2) { + const hi = j + 1 >= bands ? [[T.x + half(1), yAt(1)], [T.x - half(1), yAt(1)]] : edge((j + 1) / bands).reverse(); + [...edge(j / bands), ...hi].forEach(([x, y], i) => i ? c.lineTo(x, y) : c.moveTo(x, y)); + c.closePath(); + } + hatch(c, [T.x - T.wB, T.top, T.x + T.wB, T.base], gap, 0, rgba(hex(th.stripes[0]))); + const win = k => { const y = yAt(k), w = T.wB * 0.11, h = T.er * 0.375; return [[T.x - w, y - h], [T.x + w, y - h], [T.x + w, y + h], [T.x - w, y + h]]; }; + const dw = T.wB * 0.32, dh = T.wB * 0.5, door = [[T.x - dw / 2, T.base], ...ellipse(T.x, T.base - dh + dw / 2, dw / 2, dw / 2, 10, Math.PI, TAU), [T.x + dw / 2, T.base]]; + for (const k of [0.25, 0.58]) blank(c, win(k)); + blank(c, door); + c.beginPath(); + line(c, dens([[T.x - T.wB / 2, T.base], [T.x - T.wT / 2, T.top]], 6)); + line(c, dens([[T.x + T.wB / 2, T.base], [T.x + T.wT / 2, T.top]], 6)); + for (let j = 1; j < bands; j++) line(c, edge(j / bands)); + for (const k of [0.25, 0.58]) edges(c, win(k), TEX); + line(c, door); + c.fillStyle = Hill.INK; c.fill(); + if (night > 0.5) for (const k of [0.25, 0.58]) { poly(c, win(k)); hatch(c, [T.x - T.wB, yAt(k) - T.er, T.x + T.wB, yAt(k) + T.er], gap * 0.8, 0, rgba(s.P.beam)); } + const gw = T.wT * 0.95, deck = [[T.x - gw, T.top + 1 * u], [T.x - gw * 1.05, T.top - T.gal * 0.42], [T.x + gw * 1.05, T.top - T.gal * 0.42], [T.x + gw, T.top + 1 * u]]; + blank(c, deck); c.beginPath(); edges(c, deck, DET); c.fillStyle = Hill.INK; c.fill(); + }, + // the eye's lid hides what it covers, and its iris is only its outline, the screen inside + eye(base, s, c) { const P = s.P, { paper, beam } = P; P.paper = P.beam = Hill.C.iris = P.sky; base(s, c); P.paper = paper; P.beam = beam; }, + + // the hat's white stripe left blank, the rest of the cone hatched in the theme's colour + hat(base, s, c) { + const P = s.P, paper = P.paper; + P.paper = P.sky; base(s, c); P.paper = paper; + const g = s.g, L = g.lamp, er = g.lh.er, { by, bw, bh, sway, tip, pr } = hatGeom(s), k = s.bobbleK(), sy = by - er * 1.55; + poly(c, curve([[L.x - bw * 0.92, by - bh * 0.6], [L.x - bw * 0.5, by - er * 1.9], [L.x + er * 0.1 + sway * er, by - er * 2.85], tip, [L.x + er * 0.65 + sway * er * 1.4, by - er * 2.2], [L.x + bw * 0.9, by - bh * 0.6]], 3)); + c.save(); c.clip(); + c.beginPath(); c.rect(L.x - bw * 3, tip[1] - pr * 3, bw * 7, by - tip[1] + pr * 3); + c.moveTo(L.x - bw * 2, sy + er * 0.25); c.quadraticCurveTo(L.x, sy - er * 0.05, L.x + bw * 2, sy + er * 0.2); c.lineTo(L.x + bw * 2, sy - er * 0.25); c.quadraticCurveTo(L.x, sy - er * 0.55, L.x - bw * 2, sy - er * 0.25); c.closePath(); + if (k > 0.02) { c.moveTo(tip[0] + pr * k, tip[1] - pr * 0.6 * k); c.arc(tip[0], tip[1] - pr * 0.6 * k, pr * k, 0, TAU); } + c.clip('evenodd'); + c.beginPath(); for (let y = tip[1] - pr * 2; y < by; y += 2.6 * g.pw) { c.moveTo(L.x - bw * 3, y); c.lineTo(L.x + bw * 4, y); } + c.strokeStyle = rgba(hex(THEMES[g.theme || s.theme].hat)); c.lineWidth = 0.9 * Hill.PW; c.stroke(); + c.restore(); + }, + // its black face and ear drawn round, as everything is + sheep(base, c, u, wool, o, t) { + const fall = o.state === 'fall' || o.state === 'leap', step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 14 + o.seed) * 2 * u : 0; + c.beginPath(); + [-8, -3, 5, 10].forEach((lx, i) => line(c, [[lx * u, -10 * u], [lx * u + (fall ? 3 * u : (i % 2 ? step : -step)), 0]])); + c.fillStyle = Hill.INK; c.fill(); + const body = []; + for (let i = 0; i < 40; i++) { const a = i / 40 * TAU, r = 1 + 0.13 * Math.abs(Math.sin(a * 5 + o.seed)); body.push([Math.cos(a) * 14 * u * r, -16 * u + Math.sin(a) * 9.5 * u * r]); } + shape(c, body, wool); + const ja = joyAt(o, t); + c.save(); c.translate(-14 * u, -19 * u); c.rotate(-0.45 + (ja >= 0 ? Math.sin(ja * 24) * 0.6 * (1 - ja / 1.3) : 0)); + shape(c, ellipse(-2.4 * u, 0, 3 * u, 2.2 * u, 12), wool); + c.restore(); + head(c, u, o, t, 15, -19, [19, -7.5], 1.05, chew => { + shape(c, ellipse(11.5 * u, -23.5 * u, 3.6 * u, 1.6 * u, 10, 0, TAU, 0.5 + chew * 0.25), wool); + shape(c, ellipse(15 * u, -19 * u, 5 * u, 6.4 * u, 18, 0, TAU, -0.35), wool); + c.beginPath(); line(c, ellipse(13.5 * u, -24 * u, 5 * u, 2.6 * u, 10, Math.PI * 1.05, Math.PI * 1.95)); c.fillStyle = Hill.INK; c.fill(); + if (ja >= 0) { c.beginPath(); line(c, ellipse(16.6 * u, -19.6 * u, 1.7 * u, 1.3 * u, 8, Math.PI * 1.1, Math.PI * 1.9)); c.fillStyle = Hill.INK; c.fill(); } + else if (Hill.NAP) napEye(c, 16.6 * u, -20.5 * u, 1.5 * u, Hill.INK); + else dot(c, 16.8 * u, -20.5 * u, 1.1 * u, Hill.INK); + }); + }, + // its patches hatched, and its ear drawn round + cow(base, c, u, white, P, o, t) { + const INK = Hill.INK, step = Math.hypot(o.vx, o.vd || 0) > 2 ? Math.sin(t * 9 + o.seed) * 2 * u : 0; + c.beginPath(); + [-12, -7, 8, 13].forEach((lx, i) => line(c, [[lx * u, -11 * u], [lx * u + (i % 2 ? step : -step), 0]])); + c.fillStyle = INK; c.fill(); + const ja = joyAt(o, t), wag = ja >= 0 ? Math.sin(ja * 16) * 0.5 * (1 - ja / 1.3) : Math.sin(t * 1.3 + o.seed) * 0.12; + c.save(); c.translate(-17 * u, -20 * u); c.rotate(wag); c.translate(17 * u, 20 * u); + c.beginPath(); line(c, [[-17 * u, -20 * u], [-21 * u, -14 * u], [-20 * u, -7 * u]]); c.fill(); + dot(c, -20 * u, -6 * u, 2 * u, INK); + c.restore(); + const body = curve([[-17 * u, -22 * u], [-6 * u, -26.5 * u], [10 * u, -25.5 * u], [17 * u, -20 * u], [15 * u, -11 * u], [0, -10 * u], [-16 * u, -11 * u], [-18.5 * u, -16 * u]], 3, true); + blank(c, body); + c.save(); c.clip(); + const R = Hill.rng(o.seed + 3); + c.beginPath(); + for (let i = 0; i < 3; i++) ellipse((-11 + i * 9 + R() * 4) * u, (-21 + R() * 8) * u, (3.5 + R() * 3) * u, (2.6 + R() * 2) * u, 12, 0, TAU, R()).forEach(([x, y], j) => j ? c.lineTo(x, y) : c.moveTo(x, y)); + hatch(c, [-20 * u, -30 * u, 20 * u, -8 * u], 1.8 * Hill.PW, -0.9, INK); + c.restore(); + c.beginPath(); Hill.loop(c, body); c.fillStyle = INK; c.fill(); + head(c, u, o, t, 15, -24, [19, -13], 0.95, chew => { + shape(c, ellipse(13.6 * u, -27.5 * u, 3 * u, 1.5 * u, 10, 0, TAU, 0.4 + chew * 0.2), white); + shape(c, curve([[15 * u, -29 * u], [21 * u, -31 * u], [24.5 * u, -26 * u], [24.5 * u, -20 * u], [18 * u, -19.5 * u], [15 * u, -24 * u]], 2, true), white); + const my = (-21 + Math.max(0, chew) * 0.7) * u; + shape(c, ellipse(23 * u, my, 4 * u, 3 * u, 14), white); + dot(c, 22 * u, my, 0.7 * u, INK); dot(c, 24.6 * u, my - 0.2 * u, 0.7 * u, INK); + if (ja >= 0) { c.beginPath(); line(c, ellipse(20.2 * u, -25.8 * u, 1.5 * u, 1.1 * u, 8, Math.PI * 1.1, Math.PI * 1.9)); c.fillStyle = INK; c.fill(); } + else if (Hill.NAP) napEye(c, 20.2 * u, -26.6 * u, 1.3 * u, INK); + else dot(c, 20.2 * u, -26.6 * u, 1.05 * u, INK); + c.beginPath(); line(c, [[16.5 * u, -29.5 * u], [15 * u, -33.5 * u], [16.5 * u, -36 * u]]); line(c, [[21 * u, -31 * u], [22.5 * u, -34.5 * u], [21.5 * u, -37 * u]]); c.fillStyle = INK; c.fill(); + }); + }, + // the sheep at its desk, its face drawn round + desk(base, s, c, p) { + const g = s.g, sc = Hill.pop(p) * (g.portrait ? 1.75 : 2.1), u = g.u * sc, INK = Hill.INK, { x, y } = Hill.deskSpot(s); + if (sc < 0.05) return; + const typing = s.reduced ? 0 : s.t * 11, bob = s.reduced ? 0 : Math.sin(s.t * 2.4) * 0.6 * u; + c.save(); c.translate(x, y); + const body = []; + for (let i = 0; i < 36; i++) { const a = i / 36 * TAU, r = 1 + 0.12 * Math.abs(Math.sin(a * 5)); body.push([Math.cos(a) * 15 * u * r, -27 * u + Math.sin(a) * 12 * u * r]); } + shape(c, body); + for (const sx of [-1, 1]) shape(c, ellipse(sx * 8.5 * u, -40 * u + bob, 4.4 * u, 1.9 * u, 12, 0, TAU, sx * 0.35)); + shape(c, ellipse(0, -36 * u + bob, 6 * u, 7.5 * u, 20)); + c.beginPath(); line(c, ellipse(0, -42 * u + bob, 6.5 * u, 3.6 * u, 12, Math.PI, TAU)); c.fillStyle = INK; c.fill(); + for (const sx of [-1, 1]) dot(c, sx * 2.6 * u, -35.8 * u + bob, 0.9 * u, INK); + const top = [[-24 * u, -14 * u], [24 * u, -14 * u], [24 * u, -10.5 * u], [-24 * u, -10.5 * u]]; + blank(c, top); + c.beginPath(); edges(c, top); line(c, [[-20 * u, -10.5 * u], [-21 * u, 0]]); line(c, [[20 * u, -10.5 * u], [21 * u, 0]]); c.fillStyle = INK; c.fill(); + const lid = [[-11 * u, -14.5 * u], [-12 * u, -29 * u], [12 * u, -29 * u], [11 * u, -14.5 * u]]; + shape(c, lid); + shape(c, [[-1.6 * u, -18 * u], [-1.1 * u, -25 * u], [1.1 * u, -25 * u], [1.6 * u, -18 * u]]); + c.beginPath(); + for (const sx of [-1, 1]) { + const up = s.reduced ? 0 : Math.max(0, Math.sin(typing + (sx > 0 ? Math.PI : 0))) * 2.4 * u; + line(c, [[sx * 10 * u, -22 * u], [sx * 13 * u, -17 * u - up], [sx * 11 * u, -15.5 * u - up]]); + } + c.fillStyle = INK; c.fill(); + if (!s.reduced) { + const ph = (s.t * 1.3) % 1; + Hill.label(c, ph < 0.5 ? 'tak' : 'tak tak', 15 * u, -34 * u - ph * 6 * u, 8 * u + 2, rgba(s.P.ink, 1 - ph), 'left', -0.15); + } + const mug = [[16 * u, -14 * u], [16.5 * u, -20 * u], [21 * u, -20 * u], [20.5 * u, -14 * u]]; + blank(c, mug); c.beginPath(); edges(c, mug); line(c, ellipse(21.5 * u, -17 * u, 2 * u, 2 * u, 8, -1.2, 1.2)); c.fillStyle = INK; c.fill(); + c.restore(); + }, + // the other pyjamas drying, their stripes a line each + washing(base, s, c, p) { + const g = s.g, u = g.u, T = g.lh, sc = Hill.pop(p), INK = Hill.INK; + if (sc < 0.05) return; + const px = g.portrait ? g.W * 0.4 : g.W * 0.615, pry = s.ridgeY(px) ?? T.base; + const top = [px, pry - (g.portrait ? 48 : 92) * u * sc], end = [T.x - lerp(T.wB, T.wT, 0.3) / 2, T.base - T.h * 0.3], sag = 16 * u, rope = []; + for (let i = 0; i <= 20; i++) { const f = i / 20; rope.push([lerp(top[0], end[0], f), lerp(top[1], end[1], f) + Math.sin(f * Math.PI) * sag]); } + c.beginPath(); + line(c, [[px, pry + 3 * u], top]); + line(c, [[px - 6 * u, top[1] + 4 * u], [px, top[1] - 1 * u], [px + 6 * u, top[1] + 4 * u]]); + line(c, rope); + c.fillStyle = INK; c.fill(); + const others = Object.keys(THEMES).filter(k => k !== s.theme); + const wind = s.reduced ? 0.1 : s.windTotal; + others.forEach((id, i) => { + const f = (i + 0.7) / (others.length + 0.4), ax = lerp(top[0], end[0], f), ay = lerp(top[1], end[1], f) + Math.sin(f * Math.PI) * sag; + const k = sc * (g.portrait ? 1 : 1.45), w = 13 * u * k, h = 14 * u * k, sw = (s.reduced ? 0 : Math.sin(s.t * 2.2 + i) * 0.08) + wind * 0.12; + c.save(); c.translate(ax, ay); c.rotate(sw); + const shirt = [[-w / 2, 0], [-w * 0.85, h * 0.25], [-w * 0.62, h * 0.4], [-w * 0.42, h * 0.3], [-w * 0.42, h], [w * 0.42, h], [w * 0.42, h * 0.3], [w * 0.62, h * 0.4], [w * 0.85, h * 0.25], [w / 2, 0]]; + blank(c, shirt); + c.save(); c.clip(); c.beginPath(); + for (let j = 0; j < 3; j++) line(c, [[-w, h * (0.25 + j * 0.3)], [w, h * (0.25 + j * 0.3)]]); + c.fillStyle = rgba(s.P.paper); c.fill(); c.restore(); + c.beginPath(); edges(c, shirt); line(c, [[-w * 0.2, -2 * u], [-w * 0.2, 3 * u]]); line(c, [[w * 0.2, -2 * u], [w * 0.2, 3 * u]]); c.fillStyle = INK; c.fill(); + c.restore(); + }); + }, + // the huts: their stripes and roofs hatched, the open one's doorway full of lamplight in lines + huts(base, s, c, p) { + const g = s.g, spots = Hill.hutSpots(s), INK = Hill.INK, k0 = Hill.pop(p) * (g.portrait ? 1.3 : 1.75); + if (k0 > 0.05) { + const u = g.u * k0, a = spots[0], b = spots[3], walk = [[a.x - 30 * u, a.y], [b.x + 30 * u, b.y], [b.x + 30 * u, b.y + 4 * u], [a.x - 30 * u, a.y + 4 * u]]; + blank(c, walk); + c.beginPath(); edges(c, walk); + for (let x = a.x - 22 * u; x < b.x + 30 * u; x += 9 * u) { const y = a.y + lerp(0, b.y - a.y, (x - a.x) / (b.x - a.x)); line(c, [[x, y + 0.5 * u], [x, y + 3.5 * u]]); } + c.fillStyle = INK; c.fill(); + } + spots.forEach((q, i) => { + const sc = Hill.pop(Hill.clamp(p * 1.6 - i * 0.15)) * (g.portrait ? 1.3 : 1.75), u = g.u * sc, gap = 2.4 * g.pw; + if (sc < 0.05) return; + const on = s.ws === i + 1, w = 26 * u, h = 27 * u, x0 = q.x - w / 2, y0 = q.y - h; + const body = [[x0, q.y], [x0, y0], [x0 + w, y0], [x0 + w, q.y]], roof = [[x0 - 4 * u, y0 + 1 * u], [q.x, y0 - 14 * u], [x0 + w + 4 * u, y0 + 1 * u]]; + const dw = w * 0.5, dh = h * 0.74, dx = q.x - dw / 2, dy = q.y - dh, door = [[dx, q.y], [dx, dy], [dx + dw, dy], [dx + dw, q.y]]; + blank(c, body); + c.beginPath(); for (let j = 0; j < 4; j++) c.rect(x0 + j * w / 4, y0, w / 8, h); + hatch(c, [x0, y0, x0 + w, q.y], gap, Math.PI / 2, INK); + blank(c, roof); hatch(c, [x0 - 4 * u, y0 - 14 * u, x0 + w + 4 * u, y0], gap, -0.6, INK); + blank(c, door); + let num = [q.x, dy + dh * 0.45, 1]; + c.beginPath(); edges(c, roof); edges(c, body, DET, 0, false); edges(c, door, TEX, 0, false); + line(c, ellipse(q.x, y0 - 5 * u, 3.4 * u, 3.4 * u, 12, 0, TAU * 1.05)); + if (on) { + const leaf = [[dx + dw, q.y], [dx + dw, dy], [dx + dw * 1.55, dy + 2.5 * u], [dx + dw * 1.55, q.y - 1.2 * u]]; + edges(c, leaf); + num = [dx + dw * 1.29, dy + dh * 0.45, 0.85]; + } + c.fillStyle = INK; c.fill(); + if (on) { poly(c, door); hatch(c, [dx, dy, dx + dw, q.y], gap * 0.8, 0, rgba(s.P.beam)); dot(c, q.x, y0 - 5 * u, 2 * u, rgba(s.P.beam)); } + Hill.label(c, String(i + 1), num[0] + 0.5 * u, num[1], 11 * u * num[2] + 2, INK, 'center', 0); + }); + }, + // install: the cottage door open on the lamp, its light on the grass in lines + doorway(base, s, c, p) { + const g = s.g, u = g.u, k = g.cot, d = k.door, INK = Hill.INK, beam = rgba(s.P.beam); + if (!d) return; + c.globalAlpha = Hill.clamp(p * 1.4); + c.beginPath(); + for (let i = 0; i <= 6; i++) { const f = i / 6, x = lerp(d.x, d.x + d.w, f); line(c, [[x, k.base + 3 * u], [lerp(d.x - 30 * u, d.x + d.w + 40 * u, f), k.base + 32 * u]]); } + c.fillStyle = beam; c.fill(); + poly(c, [[d.x, k.base], [d.x, d.y], [d.x + d.w, d.y], [d.x + d.w, k.base]]); + hatch(c, [d.x, d.y, d.x + d.w, k.base], 2 * g.pw, 0, beam); + const door = [[d.x + d.w, k.base], [d.x + d.w, d.y], [d.x + d.w * 1.55, d.y + 2 * u], [d.x + d.w * 1.55, k.base - 1 * u]]; + blank(c, door); c.beginPath(); edges(c, door); c.fillStyle = INK; c.fill(); + s.style.draw.keeper(s, c, d.x + d.w * 0.5, k.base, g.portrait ? 1.05 : 1.45, 'wave', false, 0); + c.globalAlpha = 1; + }, + }, +}); +})(); diff --git a/site/terms.html b/site/terms.html index a4bccef6..06522ba7 100644 --- a/site/terms.html +++ b/site/terms.html @@ -6,14 +6,16 @@ Terms of Service · StableBar - + - + +