diff --git a/CLAUDE.md b/CLAUDE.md index 1ee056d..09bede7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ t7d.dev: the personal site of Tim Conrad. Plain HTML, CSS and a little JavaScrip - A new tool page: copy `tools/papercuts/index.html`, keep its `` placeholder, and add the tool to `tools/index.html`, the Tools section of `index.html` and `sitemap.xml`. `scripts/gen-tools.py` puts the README of the latest release into the page at deploy time. Never paste a README by hand. Test: `uv run --no-project --with markdown==3.11.3 python3 -m unittest scripts/test_gen_tools.py`. - `drafts/` holds local drafts. Git ignores it, and it is not in `SITE` or `sitemap.xml`. The repo is public, so a draft never goes into git. To publish a draft, move it to its real folder. - A new or revised thought: use the skill `adding-a-thought`. `llms.txt` and the feeds are made at deploy time (`scripts/gen-feeds.py`); do not commit them. -- Keep the Content-Security-Policy of each page strict: no inline scripts or styles, no `style=""` attributes, and no request to another site on page load. `assets/theme.js` and `assets/site.js` are the only scripts. +- Keep the Content-Security-Policy of each page strict: no inline scripts or styles, no `style=""` attributes, and no request to another site on page load. `assets/theme.js` and `assets/site.js` are the only scripts, plus the characters of each game page (`assets/play/play-kb.js`, `assets/play/play-wz.js`; see "Game pages" in `DESIGN.md`). - `master` is the source. Never edit `gh-pages` by hand: the workflow publishes it after every push to `master`, and the sealed admin app (`Tim-Conrad/sealed`) publishes encrypted sites to `sealed/` on it, a folder that exists only there. `scripts/deploy.sh` must keep `sealed/`. - Classes and scripts that the sealed login page uses (`.sealed`, `.label`, `.btn`, `.nav`, `#theme-toggle`, `/assets/site.js`) are used by another repository too. Keep them, or change `client/themes/t7d/login.html` in sealed with them. - `blog/` and `CVE/` only redirect old addresses (the thoughts were a blog until November 2026). Do not add pages there. diff --git a/DESIGN.md b/DESIGN.md index 64d92e9..28d333a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -11,6 +11,8 @@ Two rules follow from this: 1. **Black and white.** The only colour on the site is in the pictures and videos, so they are what the eye finds first. 2. **Plain words.** The site says what it holds and what a thing does. It does not sell. +The one place that breaks the calm on purpose is a game page. There, the game's own characters live on the lines of the sheet and can be played with (see "Game pages"). The rest of the site stays still and serious. + ## Wording The tone is honest and down to earth. @@ -63,8 +65,49 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano - **The front page stays short.** It says what exists and links to it: overview, the games, the latest thoughts, the research summary with a button to the write-up, the PGP key, contact. Details such as the attack steps and the disclosure timeline belong in the write-up, not here. - **Title block.** The box next to the name lists site, contents, contact and the PGP fingerprint. It holds facts only. Update "Contents" when a write-up or a game is added. - **Grid.** The faint 48 px grid appears only behind the name on the front page. It is the one decorative element. Do not put it behind text blocks. -- **Square corners.** Nothing has rounded corners or a shadow, except the phone frame, the step numbers and the dots of the timeline. +- **Square corners.** Nothing has rounded corners or a shadow, except the phone frame, the step numbers, the dots of the timeline and the soft smoke of the train. - **Spacing.** Lay out with grid or flex and `gap`. Side padding is `--pad`. +- **A game page** has, in this order: the header (with the line of what the characters can do), the reel, the live demo, the text with the feature list, and the timeline. It has no grid of screenshots: the videos show more, and the pictures made the page slow. + +## Game pages + +On the page of a game, and only there, the game's own characters live in the article. They make the page feel like the game without turning the site into a game. They never appear on the front page, in the game boxes there, in the thoughts or in the research. + +**What they are made of** + +- **The figures are pictures.** They are the game's own art, so they keep their colours, like any picture. + - Wortzauber: the pixel sprite sheets of the game, unchanged (Paul, the Schwebe-Bot, the things a word makes). + - Kleinbahn: rendered from the game's models (`src/models`, the near versions with `detail:'high'`), in side view, with 16 wheel positions per car, once by day and once by night. Render them again when the models change; do not draw them by hand. +- **Everything around them is drawn in ink.** Hills, tunnels, the station, the platform, the signal, speech bubbles and the hint line use the colour tokens, like a diagram. They follow the theme. +- **Smoke and steam are grey, never coloured, and low poly like the models.** Each puff is a faceted blob: a fan of flat triangles lit from the upper left. The exhaust leaves the chimney fast with the train's speed, drag slows it to the breeze, warm smoke keeps rising while it cools, and a shared turbulent flow folds neighbouring puffs together; puffs grow, turn lighter and fade. At speed the plume lies long and low; standing, it climbs. It comes in beats, four to a turn of the driving wheels, and is darker while the engine pulls away. Steam (the cylinders at the start, the brakes, the whistle, the safety valve) is lighter and short-lived. In the dark theme the smoke is light grey. +- **The site loads no game engine.** No three.js, no canvas: the figures are image strips moved by one small script per game page. + +**Where they stand** + +- They stand on lines that are already there: the rules between the sections, the frame of a picture, the lines of the feature list, the tops of the letters of a heading, a button. They do not get boxes, backgrounds or frames of their own. +- Kleinbahn's line is the rule under the header. The header gets the room the railway needs below its text, so nothing covers the text, and the smoke stays below it too. +- The two tunnels are one loop: what goes into the right hill comes out of the left one at once, so the line is never empty for long. Each hill reaches over the track: its paper covers the wheels and the sleepers beneath it, and it draws the rail line again. +- A figure may pass over text for a moment, but never stays on top of a link, a button or a paragraph. + +**How they behave** + +- **Alive, never pasted.** Every figure moves a little on its own: Paul breathes, looks around, walks and casts; the bot floats; the train runs, stops at the station and smokes; people get on and off; sheep graze. A figure that only stands still is wrong here. +- **They answer the reader.** A tap does one small thing (Paul jumps, the train stops or starts, a sheep hops). A drag carries or pushes (pick Paul up and throw him, push the train). A real control stays a real control: the signal is a `button`. +- **Wortzauber acts out the game.** Dotted words in the text (`.mw`) are traced letter by letter, and Paul makes the thing appear. Only words whose thing has a sprite in the game are dotted. +- **Paul follows the reader.** When his line scrolls away, he jumps down to a line on screen, or the bot lifts him up, as in the game. +- **Night is the dark theme.** In the dark theme the train shows its night pictures with lit windows. Load only the pictures of the theme in use. + +**Words** + +- One hint line under the subtitle says what you can do, in plain words: "Paul lives on this page. Pick him up, or tap a dotted word." +- Speech bubbles are short and friendly and say something true about the moment ("Back to Paul.", "Stop!", "Kuchen · cake"). No slogans, no calls to action. + +**Limits** + +- **Reduced motion.** The figures stand still in one pose (the train waits at the station) and nothing moves by itself. Taps and drags still work, because the reader starts them. +- **Weight.** The pictures of one game page stay under about 400 KB for one theme, as WebP or small PNG strips. +- **Phone first.** Check at 1440, 390 and 320 px, in both themes, by touch as well as by mouse. On a narrow screen use a shorter train and leave out what has no room (the signal), rather than shrinking the figures. +- **Access.** Each figure has `role="img"` and a label that says what it is and what a tap or drag does; the dotted words are focusable and work with Enter and Space. ## Components @@ -83,6 +126,7 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano | Animated diagram | `figure.anim` | Inline SVG that moves: lines draw (`.draw`), bars grow (`.grow`, `.grow-l`), parts appear (`.pop`, delays `.d1`-`.d8`) and numbers count up (`[data-count]`) once the figure comes on screen; a token can run along a path (``) while it is on screen, with a Pause button. `data-switch="A|B"` adds two buttons that switch the figure between two states (`.w-a`/`.w-b` show, `.hi-a`/`.hi-b` stay strong, `.off-b` fades). `[data-pick]` parts are buttons that show their item of `.pick__list` in a `.pick__panel`. A wide drawing (`.dg-w`) can have a narrow one for phones (`.dg-n`, below 600 px) instead of scrolling. Without the script, the end state and the whole list show. | | Diagram | `.dg` in a `figure` | Inline SVG drawn only with the classes in `site.css` (`.bx`, `.bx-s`, `.bx-i`, `.ln`, `.t-h`, `.t-s`, ...), which use the colour tokens, so a diagram follows the theme. Give it `` and `<desc>`. In a `.fig-wide` figure it scrolls inside its box on a phone instead of shrinking the text. No colours, no `style` attributes. | | Diagram with steps | `figure[data-steps]`, `.steps__list` | A diagram whose parts carry `data-step="n"`, with one list item per step. The list sits in a box joined to the bottom of the diagram (`.steps__panel`, surface colour). `site.js` puts the step number, the text of the step and Back, Next and Play into that box and shows one step at a time: earlier steps faint, later ones nearly hidden. Without the script the box holds the whole numbered list. Use it where a process has an order; keep a still diagram where it has none. | +| Game characters | `.play-layer`, `.play-spr`, `.play-bubble`, `.mw`, `.kb-*` | Only on a game page (see "Game pages"). One layer over `main` holds the figures, the speech bubble and the smoke (`.kb-smoke`, one SVG of low-poly puffs); `.mw` marks a dotted word; `.kb-*` is the drawn railway (hills, station, platform, signal) and the cars. | | Encrypted site login | `.sealed` | The login page of a site below `/sealed/`, written by sealed (theme `t7d`). Label "Encrypted", the title as `h1`, one hint, username, password, one filled button "Open", a mono status line. Inputs are framed in ink with square corners. | ## Rules that are easy to break @@ -91,12 +135,13 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano - **Links are underlined.** Colour cannot mark a link here. - **Keyboard focus is visible**: a 3 px ink outline. - **Phone first.** Check each change at 1440, 390 and 320 px wide, in both themes. The page must not scroll sideways. Wide tables and code scroll inside their own box. -- **Motion.** The interface does not move, apart from smooth scrolling to a section. The exceptions are recordings of real things: the videos, which loop silently and do not start by themselves for a visitor who asked for reduced motion, and the gameplay loop in a game box: a short animated WebP of the real game, loaded lazily. A visitor who asked for reduced motion gets a still instead, through the `<picture>` element. The other exceptions are diagrams in a thought. A diagram with steps changes only when the reader presses Back, Next or Play. An animated diagram (`figure.anim`) plays its drawing once when it comes on screen, and a running token moves only while the figure is on screen and stops with its Pause button. Animation explains something in the diagram; it is never decoration. A visitor who asked for reduced motion gets the end state at once, and the running token stands still until Play. +- **Motion.** The interface does not move, apart from smooth scrolling to a section. The exceptions are recordings of real things: the videos, which loop silently and do not start by themselves for a visitor who asked for reduced motion, and the gameplay loop in a game box: a short animated WebP of the real game, loaded lazily. A visitor who asked for reduced motion gets a still instead, through the `<picture>` element. The characters on a game page are another exception: they move on their own and answer the reader, and stand still for reduced motion ("Game pages"). The other exceptions are diagrams in a thought. A diagram with steps changes only when the reader presses Back, Next or Play. An animated diagram (`figure.anim`) plays its drawing once when it comes on screen, and a running token moves only while the figure is on screen and stops with its Pause button. Animation explains something in the diagram; it is never decoration. A visitor who asked for reduced motion gets the end state at once, and the running token stands still until Play. - **Pictures.** Give every picture `width`, `height` and a plain `alt` text that describes what it shows. ## Where things are - Styles and tokens: `assets/site.css` -- Scripts: `assets/theme.js` (theme before the first paint), `assets/site.js` (toggle, copy buttons, table of contents, the demo) +- Scripts: `assets/theme.js` (theme before the first paint), `assets/site.js` (toggle, copy buttons, table of contents, the demo), and on a game page its characters: `assets/play/play-wz.js` or `assets/play/play-kb.js`, with `assets/play/play.css` +- Pictures of the characters: `assets/play/` (Wortzauber sprites from the game's `art/pixel/`; Kleinbahn car strips `car-<kind>-d.webp` and `-n.webp` rendered from `src/models/train.js`) - Reels and pictures of the games: `.claude/skills/updating-product-reels/SKILL.md` - Deploy and repository layout: `README.md` diff --git a/assets/play/apple.png b/assets/play/apple.png new file mode 100644 index 0000000..817b707 Binary files /dev/null and b/assets/play/apple.png differ diff --git a/assets/play/bot.png b/assets/play/bot.png new file mode 100644 index 0000000..02f3c29 Binary files /dev/null and b/assets/play/bot.png differ diff --git a/assets/play/cake.png b/assets/play/cake.png new file mode 100644 index 0000000..482902b Binary files /dev/null and b/assets/play/cake.png differ diff --git a/assets/play/candle.png b/assets/play/candle.png new file mode 100644 index 0000000..76bcd5c Binary files /dev/null and b/assets/play/candle.png differ diff --git a/assets/play/car-box-d.webp b/assets/play/car-box-d.webp new file mode 100644 index 0000000..24ef4f4 Binary files /dev/null and b/assets/play/car-box-d.webp differ diff --git a/assets/play/car-box-n.webp b/assets/play/car-box-n.webp new file mode 100644 index 0000000..f01000e Binary files /dev/null and b/assets/play/car-box-n.webp differ diff --git a/assets/play/car-coach-d.webp b/assets/play/car-coach-d.webp new file mode 100644 index 0000000..e82537f Binary files /dev/null and b/assets/play/car-coach-d.webp differ diff --git a/assets/play/car-coach-n.webp b/assets/play/car-coach-n.webp new file mode 100644 index 0000000..3c33244 Binary files /dev/null and b/assets/play/car-coach-n.webp differ diff --git a/assets/play/car-loco-d.webp b/assets/play/car-loco-d.webp new file mode 100644 index 0000000..588d730 Binary files /dev/null and b/assets/play/car-loco-d.webp differ diff --git a/assets/play/car-loco-n.webp b/assets/play/car-loco-n.webp new file mode 100644 index 0000000..2c0adac Binary files /dev/null and b/assets/play/car-loco-n.webp differ diff --git a/assets/play/car-logs-d.webp b/assets/play/car-logs-d.webp new file mode 100644 index 0000000..8455746 Binary files /dev/null and b/assets/play/car-logs-d.webp differ diff --git a/assets/play/car-logs-n.webp b/assets/play/car-logs-n.webp new file mode 100644 index 0000000..b00ebe8 Binary files /dev/null and b/assets/play/car-logs-n.webp differ diff --git a/assets/play/car-rcoach-d.webp b/assets/play/car-rcoach-d.webp new file mode 100644 index 0000000..9da4835 Binary files /dev/null and b/assets/play/car-rcoach-d.webp differ diff --git a/assets/play/car-rcoach-n.webp b/assets/play/car-rcoach-n.webp new file mode 100644 index 0000000..0800c2a Binary files /dev/null and b/assets/play/car-rcoach-n.webp differ diff --git a/assets/play/car-tank-d.webp b/assets/play/car-tank-d.webp new file mode 100644 index 0000000..82dbe4e Binary files /dev/null and b/assets/play/car-tank-d.webp differ diff --git a/assets/play/car-tank-n.webp b/assets/play/car-tank-n.webp new file mode 100644 index 0000000..1bd4762 Binary files /dev/null and b/assets/play/car-tank-n.webp differ diff --git a/assets/play/p-child.png b/assets/play/p-child.png new file mode 100644 index 0000000..8803157 Binary files /dev/null and b/assets/play/p-child.png differ diff --git a/assets/play/p-stand.png b/assets/play/p-stand.png new file mode 100644 index 0000000..d2219d3 Binary files /dev/null and b/assets/play/p-stand.png differ diff --git a/assets/play/p-walk.png b/assets/play/p-walk.png new file mode 100644 index 0000000..acd89fe Binary files /dev/null and b/assets/play/p-walk.png differ diff --git a/assets/play/paul.png b/assets/play/paul.png new file mode 100644 index 0000000..38b0899 Binary files /dev/null and b/assets/play/paul.png differ diff --git a/assets/play/play-kb.js b/assets/play/play-kb.js new file mode 100644 index 0000000..0b15b45 --- /dev/null +++ b/assets/play/play-kb.js @@ -0,0 +1,321 @@ +// Kleinbahn page: the rule under the header is a railway (mock-up). +// The scenery is drawn in ink like the rest of the sheet: two hills with tunnels, a station building, a signal. +// The train is the game's own models (the near versions), rendered in 16 wheel positions, by day and by night. +// The two tunnels are one loop: what goes into the right hill comes out of the left one at once. +// The train stops at the station where people get on and off. The smoke is soft puffs, one burst per beat of the engine. +// Tap a car to stop or start the train, grab it and push it, set the signal. Sheep graze on the hill. +(() => { + const main = document.getElementById('main'), head = document.querySelector('.post-head'), reel = document.querySelector('.product > .reel'); + if (!main || !head || !reel) return; + const A = '/assets/play/'; + const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches; + const dark = () => { const t = document.documentElement.dataset.theme; return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; }; + main.classList.add('has-play', 'has-rail'); + const hint = document.createElement('p'); hint.className = 'label play-hint'; + hint.textContent = 'The train runs on the line below. Tap it to stop or start it, push it along, or set the signal.'; + head.appendChild(hint); + const layer = document.createElement('div'); layer.className = 'play-layer'; main.appendChild(layer); + const mk = (tag, cls, parent = layer) => { const e = document.createElement(tag); if (cls) e.className = cls; parent.appendChild(e); return e; }; + const svgEl = (w, h, cls, inner, below = 0) => { const d = mk('div', cls); d.innerHTML = `<svg viewBox="0 0 ${w} ${h + below}" width="${w}" height="${h + below}" aria-hidden="true">${inner}</svg>`; return d; }; + const put = (el, x, y, extra = '') => { el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)${extra}`; }; + let now = performance.now(); + + // ---------- the cars: frames rendered from src/models/train.js (detail:'high'), 250 px per model unit ---------- + // l, r: the picture's edges from the car's centre; dia: the wheel, so a wheel frame moves with the distance + const CAR = { + loco: { w: 241, h: 150, dia: .17, label: 'The steam engine' }, coach: { w: 200, h: 125, dia: .12, label: 'A coach' }, + rcoach: { w: 216, h: 125, dia: .12, label: 'A coach' }, box: { w: 206, h: 120, dia: .17, label: 'A box van' }, + logs: { w: 206, h: 124, dia: .17, label: 'A wagon with logs' }, tank: { w: 206, h: 128, dia: .17, label: 'A tank wagon' } }; + const FRAMES = 16; + + // ---------- layout ---------- + let W, railY, u, wide; + const measure = () => { const m = main.getBoundingClientRect(), r = reel.getBoundingClientRect(); + W = main.clientWidth; railY = r.top - m.top; wide = W >= 700; u = wide ? 125 : 92; }; + measure(); + + // ---------- the drawn scenery ---------- + const sleepers = mk('div', 'kb-sleepers'); + const BELOW = 12; + const hill = side => { // a hill with a tunnel portal on its inner side; the train passes behind it + const w = wide ? 160 : 78, ph = Math.round(CAR.loco.h * u / 250) + 16, h = ph + (wide ? 36 : 26), id = 'kbh' + side + w; + const pw = 13, px = w - pw, top = `M0 ${h * .36} C${w * .2} 0 ${w * .48} 0 ${w * .68} ${h * .1} C${w * .84} ${h * .17} ${px - 5} ${h - ph - 15} ${px} ${h - ph - 8}`; + const inner = `<defs><pattern id="${id}" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line class="kb-hatch" x1="0" y1="0" x2="0" y2="7"/></pattern></defs> + <path class="kb-hill__fill" d="${top} L${px} ${h} L0 ${h} Z"/><path d="${top} L${px} ${h} L0 ${h} Z" fill="url(#${id})"/> + <path class="kb-ink" d="${top}"/> + <rect class="kb-hill__fill kb-ink" x="${px}" y="${h - ph}" width="${pw}" height="${ph}"/> + <rect class="kb-hill__fill kb-ink" x="${px - 3}" y="${h - ph - 8}" width="${pw + 5}" height="8"/> + ${Array.from({ length: Math.floor(ph / 10) }, (_, k) => `<line class="kb-ink kb-thin" x1="${px}" y1="${h - ph + k * 10 + 10}" x2="${w}" y2="${h - ph + k * 10 + 10}"/>`).join('')} + ${Array.from({ length: Math.floor(ph / 10) }, (_, k) => `<line class="kb-ink kb-thin" x1="${px + (k % 2 ? 4 : 8)}" y1="${h - ph + k * 10}" x2="${px + (k % 2 ? 4 : 8)}" y2="${h - ph + k * 10 + 10}"/>`).join('')} + <rect class="kb-hill__fill" x="-1" y="${h - 2}" width="${w + 2}" height="${BELOW + 2}"/><line class="kb-ink kb-ground" x1="-1" y1="${h + .75}" x2="${w + 1}" y2="${h + .75}"/>`; + // the hill reaches over the track: its paper covers the wheels and the sleepers, and it draws the rail line again + const el = svgEl(w, h, 'kb-hill', inner, BELOW); if (side < 0) el.firstChild.style.transform = 'scaleX(-1)'; + // the outline as a table of heights, so sheep can stand on the slope + const path = el.querySelector('path.kb-ink'), n = path.getTotalLength(), ys = new Array(Math.ceil(w) + 1).fill(h); + for (let k = 0; k <= 200; k++) { const pt = path.getPointAtLength(n * k / 200), i = Math.round(pt.x); if (i >= 0 && i < ys.length) ys[i] = Math.min(ys[i], pt.y); } + for (let i = 1; i < ys.length; i++) if (ys[i] === h) ys[i] = ys[i - 1]; + return { el, w, h, ph, side, plateau: w * .6, yAt: x => ys[Math.max(0, Math.min(ys.length - 1, Math.round(x)))] }; }; + let HL, HR; + const building = svgEl(130, 74, 'kb-building', ` + <path class="kb-ink kb-paper" d="M14 30 L65 6 L116 30 Z"/><rect class="kb-ink kb-paper" x="20" y="30" width="90" height="44"/> + <rect class="kb-ink kb-paper" x="88" y="4" width="8" height="16"/> + <rect class="kb-ink" x="57" y="46" width="16" height="28"/><rect class="kb-ink" x="31" y="44" width="14" height="16"/><rect class="kb-ink" x="85" y="44" width="14" height="16"/> + <line class="kb-ink kb-thin" x1="38" y1="44" x2="38" y2="60"/><line class="kb-ink kb-thin" x1="92" y1="44" x2="92" y2="60"/> + <circle class="kb-ink kb-paper" cx="65" cy="22" r="5"/><line class="kb-ink kb-thin" x1="65" y1="22" x2="65" y2="18.5"/><line class="kb-ink kb-thin" x1="65" y1="22" x2="67.5" y2="22"/> + <text class="kb-name" x="65" y="40" text-anchor="middle">KLEINBAHN</text>`); + const platform = mk('div', 'kb-platform'); + const lamp = svgEl(14, 64, 'kb-lamp', '<line class="kb-ink" x1="7" y1="10" x2="7" y2="64"/><path class="kb-ink kb-paper" d="M2 10 L12 10 L10 2 L4 2 Z"/>'); + const signal = mk('button', 'kb-signal'); signal.type = 'button'; + signal.innerHTML = '<svg viewBox="0 0 34 62" aria-hidden="true"><line class="ink" x1="17" y1="6" x2="17" y2="62"/><line class="ink" x1="10" y1="61" x2="24" y2="61"/>' + + '<g class="arm"><rect class="fill" x="17" y="9" width="17" height="6"/><circle class="hole" cx="29" cy="12" r="1.6"/></g><circle class="ink" cx="17" cy="12" r="2.4"/></svg>'; + let clear = true; + const setSignal = c => { clear = c; signal.setAttribute('aria-pressed', String(c)); signal.setAttribute('aria-label', `Signal: ${c ? 'clear' : 'stop'}. Press to set it to ${c ? 'stop' : 'clear'}.`); }; + setSignal(true); + signal.addEventListener('click', () => { setSignal(!clear); if (clear && T.v < 5 && T.mode === 'run') whistle(); }); + + // ---------- the train ---------- + const T = { s: 0, v: 0, mode: 'run', cars: [], served: false, dwell: 0, inside: 3, held: null, lastPuff: 0, tunnel: 0 }; + const strip = k => `url(${A}car-${k}-${dark() ? 'n' : 'd'}.webp)`; + const build = () => { + for (const c of T.cars) c.el.remove(); + T.cars = []; + const kinds = wide ? ['loco', 'coach', 'rcoach', 'coach', 'box', 'logs', 'tank'] : ['loco', 'coach', 'box']; + for (const k of kinds) { + const d = CAR[k], w = d.w * u / 250, h = d.h * u / 250, el = mk('div', 'kb-car'); + el.style.width = w + 'px'; el.style.height = h + 'px'; el.style.backgroundImage = strip(k); el.style.backgroundSize = `${w * FRAMES}px ${h}px`; + el.setAttribute('role', 'img'); el.setAttribute('aria-label', `${d.label}. Tap to stop or start the train, drag to push it.`); + T.cars.push({ kind: k, el, w, h, x: 0, v: 0, step: Math.PI * d.dia * u / FRAMES, passengers: k === 'coach' || k === 'rcoach' }); grab(el); + } + T.len = T.cars.reduce((a, c) => a + c.w, 0); + }; + new MutationObserver(() => { night = dark(); for (const c of T.cars) c.el.style.backgroundImage = strip(c.kind); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { night = dark(); for (const c of T.cars) c.el.style.backgroundImage = strip(c.kind); }); + + const lay = () => { // the station, the signal and the hills for this width + if (!HL || HL.wide !== wide) { if (HL) { HL.el.remove(); HR.el.remove(); } HL = hill(1); HR = hill(-1); HL.wide = HR.wide = wide; } + const lw = T.cars[0].w, pass = T.cars.filter(c => c.passengers).reduce((a, c) => a + c.w, 0); + T.xs = Math.round(W - HR.w - (wide ? 36 : 10)); // where the engine's front stops + T.pl1 = Math.round(T.xs - lw - pass - 6); T.pl2 = Math.round(T.xs - lw * .3); + T.xsig = Math.round(Math.max(HL.w + 30, T.pl1 - (wide ? 70 : 30))); + // the loop: a car's right edge runs from just inside the left portal to where the right hill hides the whole car + const maxW = Math.max(...T.cars.map(c => c.w)); T.x0 = HL.w - 6; T.L = (W - HR.w + maxW + 6) - T.x0; + put(sleepers, 0, railY + 1.5); sleepers.style.width = W + 'px'; + put(HL.el, 0, railY - HL.h); put(HR.el, W - HR.w, railY - HR.h); + platform.style.width = (T.pl2 - T.pl1) + 'px'; put(platform, T.pl1, railY - 10); + const bs = wide ? 1 : .62; building.firstChild.style.width = 130 * bs + 'px'; building.firstChild.style.height = 74 * bs + 'px'; + put(building, (T.pl1 + T.pl2) / 2 - 65 * bs, railY - 10 - 74 * bs); + put(lamp, T.pl1 + 8, railY - 10 - 64 * (wide ? 1 : .8)); lamp.firstChild.style.height = 64 * (wide ? 1 : .8) + 'px'; + put(signal, T.xsig + 4, railY - 62); + signal.style.display = !wide && T.xsig + 40 > T.pl1 ? 'none' : ''; + }; + const place = s => { T.s = s; let right = s; for (const c of T.cars) { c.x = right; right -= c.w - 1; } for (const c of T.cars) c.d = disp(c.x); T.lap = lapOf(s); }; + // positions run on without end; on screen they wrap round the loop + const disp = X => T.x0 + (((X - T.x0) % T.L) + T.L) % T.L; + const lapOf = X => Math.floor((X - T.x0) / T.L); + const LD = () => disp(T.s); + + // ---------- people on the platform ---------- + const people = []; + const PH = () => .18 * u; + const person = (kind, x, state = 'wait') => { + const img = mk('img', 'play-img'); img.alt = ''; img.draggable = false; + const p = { kind, img, x, face: Math.random() < .5 ? 1 : -1, state, tx: x, t: Math.random() * 30 }; + img.src = A + (kind === 'child' ? 'p-child.png' : 'p-stand.png'); img.style.transition = 'opacity .5s'; + img.addEventListener('click', () => { if (p.state === 'wait') { p.face *= -1; say(['Hello!', 'Is it on time?', 'Morning!'][Math.floor(Math.random() * 3)], p.x, railY - 10 - PH()); } }); + people.push(p); return p; }; + const drawPerson = p => { + const h = p.kind === 'child' ? PH() * .66 : PH(), walking = p.state === 'walk' || p.state === 'board' || p.state === 'leave'; + const step = walking && Math.floor(now / 170) % 2, file = p.kind === 'child' ? 'p-child.png' : step ? 'p-walk.png' : 'p-stand.png'; + if (!p.img.src.endsWith(file)) p.img.src = A + file; + const w = h * (p.kind === 'child' ? 31 / 84 : step ? 54 / 128 : 47 / 129); + p.img.style.height = h + 'px'; p.img.style.width = w + 'px'; + const bob = walking && p.kind === 'child' ? Math.floor(now / 150) % 2 : 0; + put(p.img, p.x - w / 2, railY - 10 - h - bob, ` scaleX(${p.face})`); }; + + // ---------- sheep on the left hill ---------- + const sheep = [0, 1].map(i => { const img = mk('img', 'play-img'); img.src = A + 'sheep2.png'; img.alt = ''; img.draggable = false; img.style.zIndex = 7; + const sh = { img, x: 0, face: i ? -1 : 1, hop: 0, next: 2 + Math.random() * 5, walk: 0 }; + img.addEventListener('click', () => { hop(sh); say('Mäh', sh.x, railY - HL.h - 30); }); return sh; }); + const hop = sh => { if (!sh.hop) sh.hop = now; }; + + // ---------- speech, smoke ---------- + const bubble = mk('div', 'play-bubble'); let bubbleT = 0; + const say = (text, x, y, ms = 1600) => { bubble.textContent = text; bubble.classList.add('is-on'); put(bubble, Math.max(8, Math.min(W - 90, x - 20)), y - 34); bubbleT = now + ms; }; + // ---------- smoke and steam, low poly ---------- + // Each puff is a faceted blob like the game's models: a fan of flat triangles, lit from the upper left. + // The physics: the exhaust leaves the chimney fast with the train's speed, air drag slows it to the breeze, + // warm smoke keeps rising while it cools, a shared turbulent flow folds neighbouring puffs together, + // puffs grow as they take in air, turn lighter and fade. At speed the plume lies long and low; standing, it climbs. + const NS = 'http://www.w3.org/2000/svg'; + const smoke = mk('div', 'kb-smoke'); smoke.innerHTML = `<svg width="100%" height="100%" aria-hidden="true"></svg>`; + const sv = smoke.firstChild, parts = [], pool = []; + let night = dark(); + const facet = () => { // a unit puff: 6 to 8 corners, a little irregular, its centre nudged towards the light + const n = 6 + Math.floor(Math.random() * 3), pts = [], cx = -.12, cy = -.14; + for (let k = 0; k < n; k++) { const a = (k + Math.random() * .5) / n * Math.PI * 2, r = .82 + Math.random() * .3; pts.push([Math.cos(a) * r, Math.sin(a) * r]); } + const g = document.createElementNS(NS, 'g'), base = document.createElementNS(NS, 'polygon'); + base.setAttribute('points', pts.map(p => p.join(',')).join(' ')); base.setAttribute('fill', 'currentColor'); g.appendChild(base); + for (let k = 0; k < n; k++) { + const p1 = pts[k], p2 = pts[(k + 1) % n], mx = (p1[0] + p2[0]) / 2 - cx, my = (p1[1] + p2[1]) / 2 - cy; + const lit = (-mx - my) / Math.hypot(mx, my) / Math.SQRT2; // facing the upper left: lit; facing down right: shade + if (Math.abs(lit) < .25) continue; + const t = document.createElementNS(NS, 'polygon'); + t.setAttribute('points', `${cx},${cy} ${p1.join(',')} ${p2.join(',')}`); + t.setAttribute('fill', lit > 0 ? '#fff' : '#000'); t.setAttribute('fill-opacity', (lit > 0 ? .26 : .16) * Math.abs(lit)); + g.appendChild(t); } + return g; }; + const puff = (x, y, o) => { + if (parts.length > 170) return; + const g = pool.pop() || facet(); sv.appendChild(g); + parts.push({ g, x, y, vx: o.vx || 0, vy: o.vy || 0, r0: o.r0 || 4, gr: o.gr || 14, life: o.life || 3, a: o.a ?? .6, age: 0, steam: !!o.steam, + drag: o.drag ?? 2.2, buoy: o.buoy ?? 26, rot: Math.random() * 360, spin: (Math.random() - .5) * 50, cc: -1 }); }; + const flow = (x, y, t) => [ // a slow turbulent field: neighbours move alike + 9 * Math.sin(.031 * y + .8 * t) + 6 * Math.sin(.047 * x - .55 * t + 1.3), + 5 * Math.cos(.037 * x + .6 * t) + 4 * Math.sin(.052 * y - .9 * t) ]; + const smokeStep = dt => { + const t = now / 1000, wind = -10 * sc(), ceil = railY - (wide ? 150 : 104) * sc() * (wide ? 1 : 1.3); + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; p.age += dt * (p.y < ceil ? 2.5 : 1); const k = p.age / p.life; + if (k >= 1) { p.g.remove(); pool.push(p.g); parts.splice(i, 1); continue; } + const dr = Math.exp(-p.drag * dt), [fx, fy] = flow(p.x, p.y, t), heat = Math.exp(-p.age / 1.4); + p.vx = wind + (p.vx - wind) * dr; p.vy = p.vy * dr - p.buoy * heat * dt; + p.x += (p.vx + fx * Math.min(1, p.age * 1.5)) * dt; p.y += (p.vy + fy * Math.min(1, p.age * 1.5)) * dt; + if (p.y > railY - 4) { p.y = railY - 4; p.vy = Math.min(p.vy, 0); } // smoke does not sink into the ground + p.rot += p.spin * dt; + const r = p.r0 + p.gr * Math.sqrt(p.age), fade = Math.min(1, p.age / .08) * Math.pow(1 - k, 1.6); + const m = Math.min(1, k * 1.4), [c0, c1] = p.steam ? (night ? [238, 238] : [214, 236]) : night ? [196, 118] : [62, 176]; + const gC = Math.round(c0 + (c1 - c0) * m); + if (gC !== p.cc) { p.cc = gC; p.g.style.color = `rgb(${gC},${gC},${gC})`; } + p.g.setAttribute('transform', `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)}) rotate(${p.rot.toFixed(0)}) scale(${r.toFixed(2)})`); + p.g.setAttribute('opacity', (p.a * fade).toFixed(3)); } }; + const sc = () => u / 125; + const loco = () => T.cars[0]; + const chimney = () => { const l = loco(); return [l.d - l.w * .21, railY - l.h + 3]; }; + const visible = x => x > HL.w - 4 && x < W - HR.w + 4; + // one beat of the engine: a burst from the chimney, or out of a portal while the engine is in a tunnel + const chuff = (strong) => { + const [x, y] = chimney(), s = sc(), v = T.v; + if (!visible(x)) { const left = x <= HL.w, px = left ? HL.w + 2 : W - HR.w - 2, hh = left ? HL : HR; + puff(px, railY - hh.ph * .8, { vx: (left ? 40 : -40) * s, vy: -10, r0: 7 * s, gr: 14 * s, life: 3.2, a: .4, drag: 2.5, buoy: 18 }); return; } + for (let i = 0; i < 2; i++) puff(x + (Math.random() - .5) * 3, y - i * 3, { vx: v + (Math.random() - .5) * 14, vy: -(95 + Math.abs(v) * .3 + Math.random() * 30) * s, + r0: (3 + Math.random() * 2) * s, gr: (strong ? 15 : 12) * s * (.85 + Math.random() * .3), life: 3 + Math.random() * 1.2, a: strong ? .78 : .62, drag: 2.4, buoy: 30 * s }); }; + const cocks = () => { const l = loco(), s = sc(); // the cylinder cocks blow steam when the engine starts + for (const dir of [1, -1]) puff(l.d - l.w * .12, railY - 10 * s, { steam: true, vx: dir * (110 + Math.random() * 60) * s + T.v, vy: -10, r0: 3 * s, gr: 13 * s, life: .9, a: .75, drag: 3.5, buoy: 10 }); }; + const whistle = () => { const l = loco(), s = sc(), x = l.d - l.w * .45, y = railY - l.h * .82; + for (let i = 0; i < 6; i++) setTimeout(() => puff(x, y, { steam: true, vx: T.v, vy: -130 * s, r0: 2 * s, gr: 9 * s, life: 1.1, a: .85, drag: 3, buoy: 8 }), i * 55); + T.cocks = 1.6; for (let i = 0; i < 3; i++) setTimeout(() => chuff(true), i * 220); }; + const brakeHiss = () => { const l = loco(), s = sc(); + for (let i = 0; i < 5; i++) setTimeout(() => puff(l.d - l.w * (.15 + .4 * Math.random()), railY - 5 * s, { steam: true, vx: (Math.random() - .5) * 90 * s, vy: -12, r0: 2 * s, gr: 10 * s, life: .8, a: .6, drag: 3.5, buoy: 8 }), i * 80); }; + // ---------- pointer ---------- + function grab(el) { + el.addEventListener('pointerdown', e => { e.preventDefault(); el.setPointerCapture(e.pointerId); T.held = { el, x0: e.clientX, s0: T.s, moved: false, hist: [[now, e.clientX]] }; el.classList.add('is-held'); }); + el.addEventListener('pointermove', e => { const h = T.held; if (!h || h.el !== el) return; if (Math.abs(e.clientX - h.x0) > 5) h.moved = true; + h.hist.push([now, e.clientX]); if (h.hist.length > 6) h.hist.shift(); if (h.moved) h.target = h.s0 + (e.clientX - h.x0); }); + const up = () => { const h = T.held; if (!h || h.el !== el) return; T.held = null; el.classList.remove('is-held'); + if (!h.moved) { toggle(); return; } + const a = h.hist[0], b = h.hist[h.hist.length - 1]; T.v = Math.max(-420, Math.min(420, (b[1] - a[1]) / Math.max(.016, (b[0] - a[0]) / 1000))); + if (Math.abs(T.v) > 160) say(T.v > 0 ? 'Whoosh!' : 'Backwards?', Math.min(W - HR.w - 60, LD() - 40), railY - T.cars[0].h); }; + el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); } + const toggle = () => { const x = Math.min(W - HR.w - 60, LD() - 30); + if (T.mode === 'run') { T.mode = 'halt'; say('Stop!', x, railY - T.cars[0].h); } else { T.mode = 'run'; T.served = T.served || T.v === 0 && Math.abs(LD() - T.xs) < 2; whistle(); } }; + + // ---------- one step ---------- + const CRUISE = () => 78 * u / 125, ACC = 30, BRK = 55; + const drive = dt => { + if (T.held) { if (T.held.target != null) { const ns = T.held.target; T.v = (ns - T.s) / Math.max(dt, .001) * .3 + T.v * .7; T.s = ns; } return; } + const vt = T.mode === 'run' && T.dwell <= 0 ? CRUISE() : 0, dl = LD(); let stopAt = null; + if (T.v >= 0) { + if (!clear && dl < T.xsig - 6) stopAt = T.xsig - 8; + if (!T.served && dl < T.xs + 1 && T.mode === 'run') stopAt = stopAt == null ? T.xs : Math.min(stopAt, T.xs); + } + if (stopAt != null && T.v > 0) { + const d = stopAt - dl; + if (d <= T.v * T.v / (2 * BRK) + 1) { if (d < .6) { T.s += d; T.v = 0; brakeHiss(); } else T.v = Math.max(4, T.v - T.v * T.v / (2 * d) * dt); } + else T.v = Math.min(vt, T.v + ACC * dt); + } else if (T.v < vt) T.v = Math.min(vt, T.v + ACC * dt * (T.v < 0 ? 3 : 1)); + else { const was = T.v; T.v = Math.max(vt, T.v - (T.v > CRUISE() ? 80 : BRK) * dt); if (was > 0 && T.v === 0) brakeHiss(); } + T.s += T.v * dt; + const lap = lapOf(T.s); if (lap > T.lap) T.served = false; T.lap = lap; // a new round: the station waits again + if (!T.served && T.v === 0 && Math.abs(LD() - T.xs) < 1 && T.dwell <= 0 && T.mode === 'run') arrive(); + if (T.dwell > 0) { T.dwell -= dt; if (T.dwell <= 0) { T.served = true; whistle(); } } }; + const doors = () => T.cars.filter(c => c.passengers).map(c => c.d - c.w / 2); + const arrive = () => { + T.dwell = 6; const ds = doors(); if (!ds.length) return; + const out = Math.min(T.inside, 1 + Math.floor(Math.random() * 3)); + for (let i = 0; i < out; i++) setTimeout(() => { // get off, wait a moment, walk away + const p = person(Math.random() < .3 ? 'child' : 'adult', ds[i % ds.length], 'walk'); T.inside--; + p.img.style.opacity = 0; requestAnimationFrame(() => { p.img.style.opacity = 1; }); + p.tx = T.pl1 + 14 + Math.random() * (T.pl2 - T.pl1 - 28) * .5; p.then = 'leave'; }, 400 + i * 650); + for (const p of people.filter(p => p.state === 'wait')) { p.state = 'board'; p.tx = ds[Math.floor(Math.random() * ds.length)] + (Math.random() - .5) * 12; } }; + const peopleStep = dt => { + for (const p of [...people]) { + if (p.state === 'walk' || p.state === 'board' || p.state === 'leave') { + const d = p.tx - p.x, sp = (p.kind === 'child' ? 20 : 15) * u / 125; p.face = d >= 0 ? 1 : -1; + if (Math.abs(d) < sp * dt) { + p.x = p.tx; + if (p.state === 'board' || p.state === 'leave') { if (p.state === 'board') T.inside++; p.img.style.opacity = 0; p.state = 'gone'; + setTimeout(() => { p.img.remove(); people.splice(people.indexOf(p), 1); }, 600); } + else if (p.then === 'leave') { p.state = 'wait'; p.then = null; setTimeout(() => { if (p.state === 'wait') { p.state = 'leave'; p.tx = T.pl1 - 4; } }, 2500 + Math.random() * 2500); } + else p.state = 'wait'; + } else p.x += Math.sign(d) * sp * dt; + } else if (p.state === 'wait' && !reduced()) { p.t -= dt; if (p.t < 0) { p.t = 5 + Math.random() * 25; p.face *= -1; } } + drawPerson(p); } + const waiting = people.filter(p => p.state === 'wait' || (p.state === 'walk' && !p.then)).length; + const away = T.cars.every(c => c.d < T.pl1 + 4 || c.d - c.w > T.pl2 - 4); + if (!reduced() && T.dwell <= 0 && waiting < 3 && away && Math.random() < dt * .3) { + const p = person(Math.random() < .25 ? 'child' : 'adult', T.pl1 - 2, 'walk'); p.img.style.opacity = 0; requestAnimationFrame(() => { p.img.style.opacity = 1; }); + p.tx = T.pl1 + 18 + Math.random() * (T.pl2 - T.pl1 - 36); } }; + + let last = performance.now(); + const tick = t => { + now = t; const dt = Math.min(.05, (t - last) / 1000); last = t; + const s0 = T.s; drive(dt); + // couplings: the cars follow on a spring, so the train bunches up when it brakes and stretches when it pulls + T.cars[0].x = T.s; T.cars[0].v = T.v; + for (let i = 1; i < T.cars.length; i++) { + const c = T.cars[i], p = T.cars[i - 1], target = p.x - p.w + 1; + c.v += (160 * (target - c.x) - 15 * (c.v - p.v)) * dt; c.x += c.v * dt; + c.x = Math.max(target - 6, Math.min(target + 1.5, c.x)); } + const sp = Math.abs(T.v); + for (const c of T.cars) c.d = disp(c.x); + for (const [i, c] of T.cars.entries()) { + const f = ((FRAMES - (Math.floor(c.x / c.step) % FRAMES)) % FRAMES + FRAMES) % FRAMES; + c.el.style.backgroundPosition = `${-f * c.w}px 0`; + const bob = sp > 5 ? Math.round(Math.sin(c.x * .3 + i * 2) * .6) : 0; + put(c.el, c.d - c.w, railY - c.h + 1 + bob); } + // smoke: four beats to a turn of the driving wheels; standing, the engine simmers + T.beat = (T.beat || 0) + Math.abs(T.s - s0); + const beatLen = Math.PI * .17 * u / 4; + if (T.beat >= beatLen) { T.beat %= beatLen; chuff(T.v > 2 && T.v < CRUISE() * .7 && T.mode === 'run'); } + if (!reduced() && sp < 3 && now - T.lastPuff > 380) { T.lastPuff = now; const [x, y] = chimney(); + if (visible(x)) puff(x, y, { vx: 0, vy: -30 * sc(), r0: 2.5 * sc(), gr: 8 * sc(), life: 3, a: .34, drag: 1.5, buoy: 16 * sc() }); + if (Math.random() < .08) { const l = loco(); puff(l.d - l.w * .62, railY - l.h * .74, { steam: true, vy: -70 * sc(), r0: 2 * sc(), gr: 7 * sc(), life: 1.1, a: .7, drag: 2.5, buoy: 8 }); } } + if (T.cocks > 0) { T.cocks -= dt; if (Math.random() < dt * 14) cocks(); } + smokeStep(dt); + // sheep graze on the hill, step about, and look up when the engine comes out of the tunnel + for (const [i, sh] of sheep.entries()) { + const h = .14 * u * 1.4, w = h * 98 / 72; sh.img.style.width = w + 'px'; sh.img.style.height = h + 'px'; + sh.img.style.display = i && !wide ? 'none' : ''; + if (!sh.x) sh.x = HL.plateau * (i ? .72 : .3); + if (!reduced()) { sh.next -= dt; if (sh.next < 0) { sh.next = 4 + Math.random() * 7; if (Math.random() < .45) sh.face *= -1; else sh.walk = 1 + Math.random(); } } + if (sh.walk > 0) { sh.walk -= dt; sh.x += sh.face * 7 * dt; if (sh.x < w / 2 + 2 || sh.x > HL.plateau - w / 2) { sh.face *= -1; sh.x = Math.max(w / 2 + 2, Math.min(HL.plateau - w / 2, sh.x)); } } + const nose = loco().d; if (T.v > 30 && nose > HL.w && nose < HL.w + 30) { hop(sh); sh.face = 1; } + let hy = 0; if (sh.hop) { const k = (now - sh.hop) / 420; if (k >= 1) sh.hop = 0; else hy = Math.sin(Math.PI * k) * 9; } + put(sh.img, sh.x - w / 2, railY - HL.h + HL.yAt(sh.x) - h + 2 - hy, ` scaleX(${sh.face})`); } + peopleStep(dt); + if (bubbleT && now > bubbleT) { bubble.classList.remove('is-on'); bubbleT = 0; } + requestAnimationFrame(tick); }; + + const start = () => { + measure(); build(); lay(); + if (reduced()) { place(T.xs); T.served = true; T.mode = 'halt'; } + else { place(HL.w + T.cars[0].w + 20); T.v = CRUISE() * .8; } + for (const k of [.2, .45, .7]) person(k === .45 ? 'child' : 'adult', T.pl1 + (T.pl2 - T.pl1) * k); + requestAnimationFrame(tick); + let rt, lastW = W; + const relayout = () => { const k = LD() / lastW; measure(); if (Math.abs(W - lastW) > 40) { build(); lay(); place(k * W); lastW = W; for (const p of people) p.x = Math.max(T.pl1 + 10, Math.min(T.pl2 - 10, p.x)); } else lay(); }; + addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(relayout, 150); }); + setInterval(() => { measure(); lay(); }, 1500); }; + const go = () => (document.fonts ? document.fonts.ready : Promise.resolve()).then(start); + if (document.readyState === 'complete') go(); else addEventListener('load', go); +})(); diff --git a/assets/play/play-wz.js b/assets/play/play-wz.js new file mode 100644 index 0000000..878f580 --- /dev/null +++ b/assets/play/play-wz.js @@ -0,0 +1,376 @@ +// Wortzauber page: Paul and the Schwebe-Bot live on the lines of the article (mock-up). +// Paul walks on the rules, the feature list, the pictures and the title. Pick him up and throw him; +// tap a dotted word and he makes the thing appear. When the reader scrolls on, he follows. +(() => { + const main = document.getElementById('main'); + if (!main || !document.querySelector('.post-head')) return; + const A = '/assets/play/'; + const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches; + const G = 2200; // gravity, px/s² + main.classList.add('has-play'); + const layer = document.createElement('div'); layer.className = 'play-layer'; main.appendChild(layer); + + // ---------- sprites ---------- + const SHEETS = { + paul: { src: 'paul.png', w: 512, h: 1360, fw: 64, fh: 80, cols: 8, ax: 28, ay: 78, anims: { + idle: [0, 4, 6], cast: [8, 8, 12], walk: [16, 8, 10], jump: [24, 8, 12], land: [29, 3, 14], air: [27, 1, 1], channel: [32, 4, 8], + cheer: [40, 8, 10], run: [48, 8, 14], pickup: [96, 6, 14], drop: [104, 4, 12], fly: [112, 2, 6], + oidle: [80, 4, 6], owalk: [88, 8, 10] } }, + bot: { src: 'bot.png', w: 640, h: 864, fw: 64, fh: 96, cols: 10, ax: 32, ay: 88, anims: { + idle: [0, 4, 6], move: [10, 6, 12], wave: [20, 6, 10], carry: [40, 4, 8], point: [60, 6, 8], cheer: [70, 8, 10] } }, + cake: { src: 'cake.png', w: 44, h: 32, fw: 44, fh: 32, cols: 1, ax: 22, ay: 31, anims: { idle: [0, 1, 1] } }, + candle: { src: 'candle.png', w: 120, h: 44, fw: 40, fh: 44, cols: 3, ax: 20, ay: 43, anims: { idle: [0, 3, 5] } }, + apple: { src: 'apple.png', w: 32, h: 31, fw: 32, fh: 31, cols: 1, ax: 16, ay: 30, anims: { idle: [0, 1, 1] } }, + puff: { src: 'smoke_puff.png', w: 768, h: 128, fw: 128, fh: 128, cols: 6, ax: 64, ay: 64, scale: .5, anims: { idle: [0, 6, 14] } }, + sparkle: { src: 'summon_sparkle.png', w: 1024, h: 128, fw: 128, fh: 128, cols: 8, ax: 64, ay: 64, anims: { idle: [0, 8, 14] } }, + }; + let now = performance.now(); + const all = new Set(); + class Spr { + constructor(key, cls = 'play-spr') { + const s = this.s = SHEETS[key], k = s.scale || 1, el = this.el = document.createElement('div'); + el.className = cls; + el.style.width = s.fw * k + 'px'; el.style.height = s.fh * k + 'px'; + el.style.backgroundImage = `url(${A}${s.src})`; el.style.backgroundSize = `${s.w * k}px ${s.h * k}px`; + layer.appendChild(el); + this.x = 0; this.y = 0; this.face = 1; this.rot = 0; this.z = 1; this.play('idle'); all.add(this); + } + play(name, once = false) { if (this.anim === name && !once && !this.once) return; this.anim = name; this.t0 = now; this.once = once; this.ended = false; } + draw() { + const s = this.s, k = s.scale || 1, [f0, n, fps] = s.anims[this.anim]; + let i = Math.floor((now - this.t0) / 1000 * fps); + if (this.once) { if (i >= n) { i = n - 1; this.ended = true; } } else i %= n; + const f = f0 + i, el = this.el; + el.style.backgroundPosition = `${-(f % s.cols) * s.fw * k}px ${-Math.floor(f / s.cols) * s.fh * k}px`; + const r = this.rot ? ` rotate(${this.rot.toFixed(1)}deg)` : ''; + el.style.transformOrigin = this.rot ? `${s.ax * k}px ${8 * k}px` : `${s.ax * k}px ${s.ay * k}px`; + el.style.transform = `translate(${Math.round(this.x - s.ax * k)}px, ${Math.round(this.y - s.ay * k)}px)${r} scaleX(${this.face})`; + el.style.zIndex = this.z; + } + remove() { this.el.remove(); all.delete(this); } + } + const fx = (key, x, y) => { const f = new Spr(key, 'play-fx'); f.x = x; f.y = y; f.z = 4; f.play('idle', true); f.fx = true; return f; }; + + // ---------- the lines of the page that things can stand on ---------- + let surfs = [], W = 0, H = 0; + const mrect = () => main.getBoundingClientRect(); + const cv = document.createElement('canvas').getContext('2d'); + const textTop = el => { // a heading: the top of each letter, so Paul steps up onto the tall ones + const m = mrect(), cs = getComputedStyle(el), node = [...el.childNodes].find(n => n.nodeType === 3 && n.nodeValue.trim()); + const r = document.createRange(); r.selectNodeContents(el); const t = r.getBoundingClientRect(); + cv.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const fm = cv.measureText('Hxg'), base = t.top - m.top + (t.height - (fm.fontBoundingBoxAscent + fm.fontBoundingBoxDescent)) / 2 + fm.fontBoundingBoxAscent; + const prof = []; + if (node) for (let i = 0; i < node.nodeValue.length; i++) { + const ch = node.nodeValue[i]; if (!ch.trim()) continue; + const cr = document.createRange(); cr.setStart(node, i); cr.setEnd(node, i + 1); const b = cr.getBoundingClientRect(); + const gm = cv.measureText(ch); prof.push([b.left - m.left, b.right - m.left, base - gm.actualBoundingBoxAscent]); + } + const ys = prof.map(p => p[2]); + return { x1: t.left - m.left + 4, x2: t.right - m.left - 4, y: ys.length ? Math.min(...ys) : base - 20, prof, el }; + }; + const measure = () => { + const m = mrect(); W = main.clientWidth; H = main.scrollHeight; + const S = [], add = (x1, x2, y, el, kind) => { if (x2 - x1 > 40) S.push({ x1, x2, y: Math.round(y), el, kind }); }; + const box = el => { const r = el.getBoundingClientRect(); return { l: r.left - m.left, r: r.right - m.left, t: r.top - m.top, b: r.bottom - m.top }; }; + const h1 = document.querySelector('.post-head h1'); if (h1) { const t = textTop(h1); add(t.x1, t.x2, t.y, h1, 'title'); S[S.length - 1].prof = t.prof; } + for (const el of document.querySelectorAll('.product > *')) { const b = box(el); add(b.l, b.r, b.t, el, 'rule'); } + for (const el of document.querySelectorAll('.product h2')) { const t = textTop(el); add(t.x1, t.x2, t.y, el, 'title'); if (S.length && S[S.length - 1].el === el) S[S.length - 1].prof = t.prof; } + const feats = document.querySelector('.feats'); + if (feats) { const b = box(feats); add(b.l, b.r, b.t, feats, 'line'); for (const li of feats.children) { const c = box(li); add(c.l, c.r, c.b - 1, li, 'line'); } } + for (const el of document.querySelectorAll('.shots img, .tl .player, .product .btn')) { const b = box(el); if (b.b > b.t) add(b.l, b.r, b.t, el, 'top'); } + add(0, W, H - 2, main, 'floor'); + surfs = S; + }; + const yAt = (s, x) => { // the height of a surface at x (a heading follows its letters) + if (!s.prof || !s.prof.length) return s.y; + let y = null; for (const [a, b, t] of s.prof) if (x >= a - 2 && x <= b + 2) y = y == null ? t : Math.min(y, t); + if (y != null) return Math.round(y); + let best = s.prof[0], d = 1e9; for (const p of s.prof) { const dd = Math.min(Math.abs(x - p[0]), Math.abs(x - p[1])); if (dd < d) { d = dd; best = p; } } return Math.round(best[2]); + }; + const surfAt = (x, yFrom, yTo) => { // the first surface crossed going down from yFrom to yTo at x + let best = null, by = 0; + for (const s of surfs) { if (x < s.x1 || x > s.x2) continue; const y = yAt(s, x); if (y >= yFrom && y <= yTo && (!best || y < by)) { best = s; by = y; } } + return best; + }; + const surfBelow = (x, y) => surfAt(x, y, 1e9); + const view = () => { const m = mrect(); return { t: -m.top + 56, b: -m.top + innerHeight }; }; + + // ---------- Paul, the bot and the things ---------- + const P = { spr: new Spr('paul'), x: 0, y: 0, vx: 0, vy: 0, state: 'ground', surf: null, mode: 'idle', timer: 1.5, tx: 0, after: null, carry: null, goal: null, quiet: 0 }; + const B = { spr: new Spr('bot'), x: 0, y: 0, state: 'follow', vx: 0, vy: 0, tx: 0, ty: 0 }; + P.spr.z = 3; B.spr.z = 2; + const things = []; + const bubble = document.createElement('div'); bubble.className = 'play-bubble'; bubble.setAttribute('aria-live', 'polite'); layer.appendChild(bubble); + let bubbleT = 0; + const say = (text, ms = 3200) => { bubble.textContent = text; bubble.classList.add('is-on'); bubbleT = now + ms; }; + + const paulAnim = () => { + if (P.state === 'held') return 'fly'; + if (P.state === 'air' || P.state === 'hop') return P.jumping ? 'jump' : 'air'; + if (P.state === 'lift') return 'fly'; + if (P.mode === 'walk') return P.carry ? 'owalk' : (P.fast ? 'run' : 'walk'); + if (P.mode === 'anim') return P.animName; + return P.carry ? 'oidle' : 'idle'; + }; + const doAnim = (name, then) => { P.mode = 'anim'; P.animName = name; P.spr.play(name, true); P.after = then || null; }; + const walkTo = (x, then, fast = false) => { const s = P.surf; P.tx = Math.max(s.x1 + 12, Math.min(s.x2 - 12, x)); P.mode = 'walk'; P.after = then || null; P.fast = fast; }; + const idle = (t = 1 + Math.random() * 2.5) => { P.mode = 'idle'; P.timer = t; P.after = null; }; + + // go to a point on another surface: hop down onto it, or let the bot lift him up + const travel = (s, x, then) => { + x = Math.max(s.x1 + 14, Math.min(s.x2 - 14, x)); + if (s === P.surf) { walkTo(x, then, true); return; } + if (P.carry) dropCarry(); + if (s.y > P.y) { // down: a hop that only lands on s + const v = view(); if (P.y < v.t - 200) P.y = v.t - 120; + const vy0 = -420, dy = s.y - P.y, t = (-vy0 + Math.sqrt(vy0 * vy0 + 2 * G * dy)) / G; + P.state = 'hop'; P.hopTo = s; P.vy = vy0; P.vx = (x - P.x) / t; P.jumping = false; P.next = then; P.face = P.vx >= 0 ? 1 : -1; + } else { // up: the bot carries him + const v = view(); if (P.y > v.b + 200) { P.y = v.b + 120; B.y = P.y - 70; } + P.state = 'lift'; P.liftTo = s; P.liftX = x; P.next = then; B.state = 'lift'; say('I can lift you!', 1800); + } + }; + + const land = (s, vy) => { + P.state = 'ground'; P.surf = s; P.y = yAt(s, P.x); P.vx = 0; P.vy = 0; P.spr.rot = 0; P.jumping = false; + if (vy > 700) { fx('puff', P.x, P.y - 10); } + if (P.next) { const n = P.next; P.next = null; idle(.15); P.after = n; P.timer = .15; return; } + if (vy > 1300) { doAnim('land', () => { say(['Wheee!', 'Again!', 'That was high.'][Math.floor(Math.random() * 3)]); doAnim('cheer', () => idle()); }); } + else doAnim('land', () => idle(.8)); + }; + + // ---------- things that words make ---------- + const mkThing = (key, x, y) => { + if (things.length >= 6) { const old = things.shift(); if (P.carry === old) P.carry = null; fx('puff', old.x, old.y - 10); old.spr.remove(); } + const t = { key, spr: new Spr(key), x, y, vx: (Math.random() - .5) * 60, vy: -260, state: 'air', surf: null }; + t.spr.z = 2; things.push(t); grabbable(t.spr.el, t, 'thing'); return t; + }; + const dropCarry = () => { const t = P.carry; if (!t) return; P.carry = null; t.state = 'air'; t.vx = P.face * 80; t.vy = -150; t.spr.z = 2; }; + + // ---------- the brain ---------- + const decide = () => { + if (P.after) { const a = P.after; P.after = null; a(); return; } + if (reduced()) { idle(5); return; } + const s = P.surf; + const near = things.find(t => t.state === 'ground' && t.surf === s && !P.carry && Math.abs(t.x - P.x) < 260); + const r = Math.random(); + if (near && r < .7) { walkTo(near.x - Math.sign(near.x - P.x || 1) * 12, () => pickUp(near)); return; } + if (P.carry && r < .25) { doAnim('drop', () => { const t = P.carry; P.carry = null; t.state = 'ground'; t.surf = s; t.x = P.x + P.face * 22; t.y = s.y; t.spr.z = 2; idle(1.5); }); return; } + if (r < .62) { walkTo(s.x1 + 20 + Math.random() * (s.x2 - s.x1 - 40)); return; } + if (r < .72 && !P.carry) { doAnim('cast', () => { fx('sparkle', P.x + P.face * 30, P.y - 40); idle(1.2); }); return; } + if (r < .8 && !P.carry) { doAnim('channel', () => idle(.6)); return; } + idle(1.5 + Math.random() * 3); + }; + const pickUp = t => { + if (t.state !== 'ground' || P.carry) { idle(); return; } + P.face = t.x >= P.x ? 1 : -1; + doAnim('pickup', () => { if (t.state === 'ground') { P.carry = t; t.state = 'carried'; t.spr.z = 4; } doAnim('cheer', () => idle(1)); }); + }; + + // ---------- pointer: pick up Paul, the bot and the things ---------- + let held = null; + function grabbable(el, obj, kind) { + el.addEventListener('pointerdown', e => { + if (held) return; e.preventDefault(); el.setPointerCapture(e.pointerId); + const m = mrect(), px = e.clientX - m.left, py = e.clientY - m.top; + held = { obj, kind, el, dx: obj.x - px, dy: obj.y - py, x0: px, y0: py, t0: now, moved: false, hist: [[now, px, py]] }; + }); + el.addEventListener('pointermove', e => { + if (!held || held.el !== el) return; + const m = mrect(), px = e.clientX - m.left, py = e.clientY - m.top; + held.hist.push([now, px, py]); if (held.hist.length > 6) held.hist.shift(); + if (!held.moved && Math.hypot(px - held.x0, py - held.y0) > 5) { held.moved = true; startHold(held); } + if (held.moved) { held.tx = px + held.dx; held.ty = py + held.dy; } + }); + const up = () => { + if (!held || held.el !== el) return; + const h = held; held = null; el.classList.remove('is-held'); + if (!h.moved) { tap(h); return; } + const a = h.hist[0], b = h.hist[h.hist.length - 1], dt = Math.max(16, b[0] - a[0]) / 1000; + let vx = (b[1] - a[1]) / dt, vy = (b[2] - a[2]) / dt; const sp = Math.hypot(vx, vy); if (sp > 1800) { vx *= 1800 / sp; vy *= 1800 / sp; } + release(h, vx, vy); + }; + el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); + } + const startHold = h => { + h.el.classList.add('is-held'); h.tx = h.obj.x; h.ty = h.obj.y; + if (h.kind === 'paul') { if (P.carry) dropCarry(); P.goal = null; P.next = null; P.after = null; P.state = 'held'; P.quiet = 0; + if (!P.wasHeld) { P.wasHeld = true; setTimeout(() => say('Careful with Paul!', 2200), 300); } } + if (h.kind === 'bot') { B.state = 'held'; say('Hey!', 1200); } + if (h.kind === 'thing') { if (P.carry === h.obj) P.carry = null; h.obj.state = 'held'; h.obj.spr.z = 4; } + }; + const release = (h, vx, vy) => { + if (h.kind === 'paul') { P.state = 'air'; P.vx = vx; P.vy = vy; P.jumping = false; } + if (h.kind === 'bot') { B.state = 'follow'; B.vx = vx; B.vy = vy; setTimeout(() => say('Back to Paul.', 1600), 400); } + if (h.kind === 'thing') { h.obj.state = 'air'; h.obj.vx = vx; h.obj.vy = vy; h.obj.spr.z = 2; } + }; + const tap = h => { + if (h.kind === 'paul' && P.state === 'ground') { P.goal = null; P.after = null; P.state = 'air'; P.vy = -560; P.vx = 0; P.jumping = true; P.spr.play('jump', true); } + if (h.kind === 'bot') { B.spr.play('wave', true); say(['Hello!', 'I am the Schwebe-Bot.', 'Tap a dotted word.'][Math.floor(Math.random() * 3)]); } + if (h.kind === 'thing' && h.obj.state === 'ground') { h.obj.state = 'air'; h.obj.vy = -380; h.obj.vx = (Math.random() - .5) * 120; } + }; + grabbable(P.spr.el, P, 'paul'); grabbable(B.spr.el, B, 'bot'); + P.spr.el.setAttribute('role', 'img'); P.spr.el.setAttribute('aria-label', 'Paul, the hero of Wortzauber. Drag to pick him up.'); + B.spr.el.setAttribute('role', 'img'); B.spr.el.setAttribute('aria-label', 'The Schwebe-Bot'); + + // ---------- dotted words: write, trace, appear ---------- + const WORDS = { cake: ['cake', 'Kuchen'], candle: ['candle', 'Kerze'], candles: ['candle', 'Kerze'], apples: ['apple', 'Apfel'], apple: ['apple', 'Apfel'] }; + const re = /\b(cake|candles?|apples?)\b/g; + for (const root of document.querySelectorAll('.product__about .prose p, .feats span')) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT), nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const n of nodes) { + const txt = n.nodeValue; if (!re.test(txt)) continue; re.lastIndex = 0; + const frag = document.createDocumentFragment(); let last = 0, m; + while ((m = re.exec(txt))) { + frag.append(txt.slice(last, m.index)); + const w = document.createElement('span'); w.className = 'mw'; w.textContent = m[1]; w.tabIndex = 0; w.setAttribute('role', 'button'); + w.dataset.k = m[1]; w.setAttribute('aria-label', `${m[1]}: Paul makes it appear`); frag.append(w); last = m.index + m[1].length; + } + frag.append(txt.slice(last)); n.replaceWith(frag); + } + } + const magic = w => { + if (w.classList.contains('is-tracing')) return; + const [key, de] = WORDS[w.dataset.k]; + w.classList.remove('is-traced'); void w.offsetWidth; w.classList.add('is-tracing'); + setTimeout(() => { w.classList.add('is-traced'); }, 900); + setTimeout(() => w.classList.remove('is-tracing', 'is-traced'), 2600); + measure(); + const m = mrect(), r = w.getBoundingClientRect(), wx = (r.left + r.right) / 2 - m.left, wy = r.top - m.top; + const s = surfBelow(wx, r.bottom - m.top + 2); + say(`${de} · ${key}`, 2600); + let done = false; + const appear = () => { if (done) return; done = true; fx('sparkle', wx, wy + 8); setTimeout(() => mkThing(key, wx, wy + 14), 180); }; + if (P.state === 'ground' && s && !reduced()) { + const side = P.x < wx ? -1 : 1; + P.goal = 'word'; + travel(s, wx + side * 46, () => { + P.goal = null; P.face = wx >= P.x ? 1 : -1; + doAnim('cast', () => { appear(); doAnim('cheer', () => idle(.8)); }); + setTimeout(appear, 2600); + }); + setTimeout(appear, 5200); // he could not get there: the thing appears anyway + } else setTimeout(appear, 700); + }; + document.querySelectorAll('.mw').forEach(w => { + w.addEventListener('click', () => magic(w)); + w.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); magic(w); } }); + }); + + // ---------- the loop ---------- + const fall = (o, dt, only) => { // gravity and landing; returns the surface it landed on + const y0 = o.y; o.vy += G * dt; o.y += o.vy * dt; o.x += o.vx * dt; o.vx *= Math.pow(.6, dt); + if (o.x < 8) { o.x = 8; o.vx = Math.abs(o.vx) * .5; } if (o.x > W - 8) { o.x = W - 8; o.vx = -Math.abs(o.vx) * .5; } + if (o.vy > 0) { + const oy = only ? yAt(only, o.x) : 0, s = only ? (oy >= y0 && oy <= o.y && o.x >= only.x1 - 30 && o.x <= only.x2 + 30 ? only : null) : surfAt(o.x, y0, o.y); + if (s) { o.x = Math.max(s.x1 + 4, Math.min(s.x2 - 4, o.x)); o.y = yAt(s, o.x); return s; } + } + if (o.y > H) { o.y = H - 2; return surfs[surfs.length - 1]; } + return null; + }; + let pointer = null; + addEventListener('pointermove', e => { const m = mrect(); pointer = { x: e.clientX - m.left, y: e.clientY - m.top, t: now }; }, { passive: true }); + let offT = 0; + + let last = performance.now(); + const tick = t => { + now = t; const dt = Math.min(.05, (t - last) / 1000); last = t; + // Paul + if (P.state === 'held' && held) { + const px = P.x; P.x += (held.tx - P.x) * Math.min(1, dt * 18); P.y += (held.ty - P.y) * Math.min(1, dt * 18); + const vx = (P.x - px) / dt; P.spr.rot += (Math.max(-28, Math.min(28, -vx * .05)) - P.spr.rot) * Math.min(1, dt * 8); + if (Math.abs(vx) > 30) P.face = vx > 0 ? 1 : -1; + } else if (P.state === 'air') { + P.spr.rot *= .9; const vy = P.vy; const s = fall(P, dt); if (s) land(s, vy); + } else if (P.state === 'hop') { + const vy = P.vy; const s = fall(P, dt, P.hopTo); if (s) land(s, Math.min(vy, 600)); + } else if (P.state === 'lift') { + const tx = P.liftX, ty = yAt(P.liftTo, P.liftX); + if (Math.hypot(B.x - P.x, B.y - (P.y - 66)) > 12 && !P.lifting) { /* the bot comes first */ } + else { P.lifting = true; P.x += (tx - P.x) * Math.min(1, dt * 1.6); P.y += (ty - 12 - P.y) * Math.min(1, dt * 1.6); + if (Math.abs(P.x - tx) < 3 && Math.abs(P.y - (yAt(P.liftTo, tx) - 12)) < 3) { P.lifting = false; P.state = 'air'; P.vx = 0; P.vy = 0; B.state = 'follow'; P.next = P.next || null; } } + } else if (P.state === 'ground') { + if (P.mode === 'walk') { + const sp = P.fast ? 110 : 46, d = P.tx - P.x; P.face = d >= 0 ? 1 : -1; + if (Math.abs(d) <= sp * dt) { P.x = P.tx; P.mode = 'idle'; P.timer = .1; } else P.x += Math.sign(d) * sp * dt; + } else if (P.mode === 'anim') { if (P.spr.ended) { P.mode = 'idle'; P.timer = 0; if (!P.after) P.timer = .4; } } + else { P.timer -= dt; if (pointer && now - pointer.t < 1500 && Math.abs(pointer.x - P.x) < 220 && Math.abs(pointer.y - P.y) < 160) P.face = pointer.x >= P.x ? 1 : -1; + if (P.timer <= 0) decide(); } + // a line that moved (resize, a picture that loaded) + if (P.surf) { const gy = yAt(P.surf, P.x), d = gy - P.y; // step up onto tall letters with a little hop, down with a drop + if (Math.abs(d) > 1) P.y += d * Math.min(1, dt * (d < 0 ? 16 : 22)); else P.y = gy; } + } + const pa = paulAnim(); if (P.mode !== 'anim' || P.state !== 'ground') P.spr.play(pa); P.spr.x = P.x; P.spr.y = P.y; P.spr.face = P.face; + if (P.carry) { const c = P.carry; c.x = P.x + P.face * 2; c.y = P.y - 70; } + + // the bot + const bob = 4 * Math.sin(now / 430); + if (B.state === 'held' && held) { B.x += (held.tx - B.x) * Math.min(1, dt * 18); B.y += (held.ty - B.y) * Math.min(1, dt * 18); B.spr.play('wave'); } + else { + let tx, ty, k = 3; + if (B.state === 'lift') { tx = P.x; ty = P.y - 66; k = P.lifting ? 30 : 5; } + else if (P.state === 'held') { tx = P.x - 60 * P.face; ty = P.y - 40; k = 4; } + else { tx = P.x - 48 * P.face; ty = P.y - 74 + bob; } + if (B.vx || B.vy) { B.x += B.vx * dt; B.y += B.vy * dt; B.vx *= Math.pow(.02, dt); B.vy *= Math.pow(.02, dt); if (Math.hypot(B.vx, B.vy) < 20) B.vx = B.vy = 0; } + const dx = tx - B.x, dy = ty - B.y; B.x += dx * Math.min(1, dt * k); B.y += dy * Math.min(1, dt * k); + if (!B.spr.once || B.spr.ended) B.spr.play(B.state === 'lift' ? 'carry' : (Math.hypot(dx, dy) > 14 ? 'move' : 'idle')); + if (Math.abs(dx) > 6) B.spr.face = dx > 0 ? 1 : -1; else if (B.state !== 'lift') B.spr.face = P.face; + } + B.spr.x = B.x; B.spr.y = B.y; + { const bw = bubble.offsetWidth, bh = bubble.offsetHeight, vt = view().t; let by = B.y - 52 - bh, bx = B.x - bw / 2; + if (by < Math.max(4, vt + 4)) { by = B.y - 40; bx = B.spr.face > 0 ? B.x + 26 : B.x - 26 - bw; bubble.classList.add('is-side'); } else bubble.classList.remove('is-side'); + bubble.style.transform = `translate(${Math.round(Math.max(8, Math.min(W - bw - 8, bx)))}px, ${Math.round(by)}px)`; } + if (bubbleT && now > bubbleT) { bubble.classList.remove('is-on'); bubbleT = 0; } + + // things + for (const o of things) { + if (o.state === 'held' && held && held.obj === o) { o.x += (held.tx - o.x) * Math.min(1, dt * 18); o.y += (held.ty - o.y) * Math.min(1, dt * 18); } + else if (o.state === 'air') { + const vy = o.vy, s = fall(o, dt); + // Paul catches a thing that flies at him + if (!P.carry && P.state === 'ground' && Math.abs(o.x - P.x) < 26 && o.y > P.y - 90 && o.y < P.y - 40 && vy > -200) { + P.carry = o; o.state = 'carried'; o.spr.z = 4; P.after = null; doAnim('cheer', () => idle(1)); say('Got it!', 1400); + } else if (s) { if (vy > 500) { o.vy = -vy * .3; o.vx *= .6; } else { o.state = 'ground'; o.surf = s; o.y = s.y; o.vx = 0; o.vy = 0; } } + } else if (o.state === 'ground' && o.surf) o.y = yAt(o.surf, o.x); + o.spr.x = o.x; o.spr.y = o.y; + } + + // follow the reader: off screen for a while, he comes along + if (!held && P.state === 'ground' && !P.goal && !reduced()) { + const v = view(), off = P.y < v.t - 10 ? 'up' : P.y > v.b + 30 ? 'down' : null; + if (off) { offT += dt; if (offT > 1.4) { offT = 0; + const cands = surfs.filter(s => s.y > v.t + 130 && s.y < v.b - 50 && s.kind !== 'floor'); + if (cands.length) { const s = off === 'up' ? cands.reduce((a, b) => (a.y < b.y ? a : b)) : cands.reduce((a, b) => (a.y > b.y ? a : b)); + P.goal = 'follow'; travel(s, Math.max(s.x1 + 40, Math.min(s.x2 - 40, P.x)), () => { P.goal = null; idle(.6); }); } } } + else offT = 0; + } + + for (const s of all) { s.draw(); if (s.fx && s.ended) s.remove(); } + requestAnimationFrame(tick); + }; + + // ---------- start ---------- + const start = () => { + measure(); + const t = surfs.find(s => s.kind === 'title') || surfs[0]; + P.surf = t; P.x = t.x2 - 30; P.y = t.y; P.face = -1; + B.x = P.x + 60; B.y = P.y - 80; + const head = document.querySelector('.post-head'); + const hint = document.createElement('p'); hint.className = 'label play-hint'; + hint.textContent = 'Paul lives on this page. Pick him up, or tap a dotted word.'; head.appendChild(hint); + measure(); P.surf = (W < 700 ? surfs.find(s => s.kind === 'rule') : surfs.find(s => s.kind === 'title')) || surfs[0]; P.x = W < 700 ? W * .7 : P.surf.x2 - 30; P.y = yAt(P.surf, P.x); + B.x = P.x + 50; B.y = P.y - 80; + setTimeout(() => say('Hello! You can pick Paul up.', 3400), 1400); + setTimeout(() => { if (!P.wasHeld) say('Or tap a dotted word.', 3000); }, 9000); + requestAnimationFrame(tick); + let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { const old = P.surf; measure(); P.surf = surfs.find(s => s.el === old.el && s.kind === old.kind) || surfBelow(P.x, P.y - 200) || surfs[0]; + P.x = Math.max(P.surf.x1 + 10, Math.min(P.surf.x2 - 10, P.x)); P.y = yAt(P.surf, P.x); + for (const o of things) if (o.surf) { o.state = 'air'; o.y -= 20; } }, 200); }); + setInterval(() => { const old = P.surf; measure(); if (old) { const n = surfs.find(s => s.el === old.el && s.kind === old.kind); if (n) P.surf = n; } + for (const o of things) if (o.surf) { const n = surfs.find(s => s.el === o.surf.el && s.kind === o.surf.kind); if (n) o.surf = n; } }, 1500); + }; + const go = () => (document.fonts ? document.fonts.ready : Promise.resolve()).then(start); + if (document.readyState === 'complete') go(); else addEventListener('load', go); +})(); diff --git a/assets/play/play.css b/assets/play/play.css new file mode 100644 index 0000000..1953626 --- /dev/null +++ b/assets/play/play.css @@ -0,0 +1,48 @@ +/* the game characters that live on a game page (mock-up) */ +#main.has-play { position: relative; } +.play-layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 5; } +.play-spr { position: absolute; left: 0; top: 0; background-repeat: no-repeat; image-rendering: pixelated; pointer-events: auto; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; } +.play-spr:active, .play-spr.is-held { cursor: grabbing; } +.play-fx { position: absolute; left: 0; top: 0; background-repeat: no-repeat; image-rendering: pixelated; pointer-events: none; } +.play-img { position: absolute; left: 0; top: 0; max-width: none; pointer-events: auto; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-user-drag: none; } +.play-img.is-static { pointer-events: none; } +.play-ring { position: absolute; left: 0; top: 0; width: 10px; height: 10px; margin: -5px 0 0 -5px; border: 1px solid var(--ink); border-radius: 50%; pointer-events: none; } +.play-bubble { position: absolute; left: 0; top: 0; padding: .35rem .55rem; background: var(--bg); color: var(--ink); border: 1px solid var(--ink); font: 500 var(--step--1)/1.3 var(--mono); letter-spacing: .04em; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity .2s; } +.play-bubble.is-on { opacity: 1; } +.play-bubble::after { content: ""; position: absolute; left: 50%; bottom: -6px; width: 9px; height: 9px; margin-left: -5px; background: var(--bg); border-right: 1px solid var(--ink); border-bottom: 1px solid var(--ink); transform: rotate(45deg); } +.play-hint { margin-top: .25rem; } + +/* Wortzauber: words that make things appear */ +.mw { text-decoration: underline dotted; text-decoration-thickness: 2px; text-underline-offset: 4px; cursor: pointer; border-radius: 0; } +.mw:hover { background: var(--ink); color: var(--bg); } +.mw.is-tracing, .mw.is-tracing:hover { background: none; color: transparent; -webkit-text-stroke: 1px var(--ink); text-decoration-style: solid; transition: color .9s steps(6); } +.mw.is-traced, .mw.is-traced:hover { background: none; color: var(--ink); -webkit-text-stroke: 0; transition: color .9s; } + +/* Kleinbahn: the railway on the rule under the header, with scenery drawn in ink */ +.has-rail .post-head { padding-bottom: calc(clamp(1.75rem, 4.5vw, 3.5rem) + 8.5rem); } +@media (max-width: 700px) { .has-rail .post-head { padding-bottom: calc(clamp(1.75rem, 4.5vw, 3.5rem) + 6rem); } } +.kb-sleepers { position: absolute; left: 0; top: 0; height: 6px; background: repeating-linear-gradient(90deg, var(--ink) 0 1.5px, transparent 1.5px 13px); pointer-events: none; } +.kb-hill, .kb-building, .kb-lamp { position: absolute; left: 0; top: 0; pointer-events: none; } +.kb-hill { z-index: 6; } .kb-hill svg, .kb-building svg, .kb-lamp svg { display: block; overflow: visible; } +.kb-hill__fill { fill: var(--bg); } +.kb-hatch { stroke: var(--soft); stroke-width: 1; } +.kb-ink { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-linejoin: round; } +.kb-ink.kb-thin { stroke-width: 1; } +.kb-ink.kb-paper, .kb-hill__fill.kb-ink { fill: var(--bg); } +.kb-name { font: 500 7px var(--mono); letter-spacing: .12em; fill: var(--ink); } +.kb-building { z-index: 1; } .kb-lamp { z-index: 2; } +.kb-platform { position: absolute; left: 0; top: 0; height: 10px; border: 1px solid var(--ink); border-bottom: 0; background: var(--surface); pointer-events: none; z-index: 2; } +.kb-signal { position: absolute; left: 0; top: 0; width: 34px; height: 62px; padding: 0; border: 0; background: none; cursor: pointer; pointer-events: auto; color: var(--ink); z-index: 3; } +.kb-signal svg { display: block; width: 100%; height: 100%; overflow: visible; } +.kb-signal .arm { transform-origin: 17px 12px; transition: transform .45s cubic-bezier(.3, 1.6, .5, 1); } +.kb-signal[aria-pressed="true"] .arm { transform: rotate(-45deg); } +.kb-signal .ink { stroke: currentColor; stroke-width: 1.5; fill: none; } +.kb-signal .fill { fill: currentColor; } +.kb-signal .hole { fill: var(--bg); stroke: currentColor; stroke-width: 1.5; } +.kb-signal:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; } +.kb-car { position: absolute; left: 0; top: 0; background-repeat: no-repeat; pointer-events: auto; cursor: grab; touch-action: none; z-index: 4; } +.kb-car.is-held { cursor: grabbing; } +.has-rail .play-img { z-index: 3; } .has-rail .play-ring { z-index: 7; } .has-rail .play-bubble { z-index: 8; } +.kb-smoke { position: absolute; inset: 0; pointer-events: none; z-index: 5; } +.kb-smoke svg { display: block; overflow: visible; } +.kb-ground { stroke-width: 1.5; } diff --git a/assets/play/sheep2.png b/assets/play/sheep2.png new file mode 100644 index 0000000..77a6cbe Binary files /dev/null and b/assets/play/sheep2.png differ diff --git a/assets/play/smoke_puff.png b/assets/play/smoke_puff.png new file mode 100644 index 0000000..ee385b6 Binary files /dev/null and b/assets/play/smoke_puff.png differ diff --git a/assets/play/summon_sparkle.png b/assets/play/summon_sparkle.png new file mode 100644 index 0000000..80f7094 Binary files /dev/null and b/assets/play/summon_sparkle.png differ diff --git a/projects/kleinbahn/index.html b/projects/kleinbahn/index.html index d8be74b..02b4708 100644 --- a/projects/kleinbahn/index.html +++ b/projects/kleinbahn/index.html @@ -18,6 +18,8 @@ <link rel="stylesheet" href="/assets/site.css"> <script src="/assets/theme.js"></script> <script src="/assets/site.js" defer></script> +<link rel="stylesheet" href="/assets/play/play.css"> +<script src="/assets/play/play-kb.js" defer></script> </head> <body> <a class="skip" href="#main">Skip to content</a> @@ -84,13 +86,6 @@ <h2 id="play-h">Play it here</h2> </ul> </div> - <div class="shots"> - <figure><img src="/assets/projects/kleinbahn-1.jpg" width="1280" height="720" alt="A steam train crossing a road next to a carousel." loading="lazy"></figure> - <figure><img src="/assets/projects/kleinbahn-2.jpg" width="1280" height="720" alt="The town centre with timber-framed houses, a windmill and a train passing." loading="lazy"></figure> - <figure><img src="/assets/projects/kleinbahn-3.jpg" width="1280" height="720" alt="A space shuttle lifting off from the launch site in a cloud of smoke." loading="lazy"></figure> - <figure><img src="/assets/projects/kleinbahn-4.jpg" width="1280" height="720" alt="The main island at night with street lamps lit." loading="lazy"></figure> - </div> - <section class="changes" id="changes" aria-labelledby="changes-h"> <h2 id="changes-h">How it changed</h2> <p class="changes__intro">One entry per merged pull request that changed play, newest first. Each clip is filmed from the game at the state of that pull request.</p> diff --git a/projects/wortzauber/index.html b/projects/wortzauber/index.html index ff3fce1..81eaf51 100644 --- a/projects/wortzauber/index.html +++ b/projects/wortzauber/index.html @@ -18,6 +18,8 @@ <link rel="stylesheet" href="/assets/site.css"> <script src="/assets/theme.js"></script> <script src="/assets/site.js" defer></script> +<link rel="stylesheet" href="/assets/play/play.css"> +<script src="/assets/play/play-wz.js" defer></script> </head> <body> <a class="skip" href="#main">Skip to content</a> @@ -84,13 +86,6 @@ <h2 id="play-h">Play it here</h2> </ul> </div> - <div class="shots"> - <figure><img src="/assets/projects/wortzauber-1.jpg" width="1280" height="720" alt="The word apfel being traced letter by letter on school lines." loading="lazy"></figure> - <figure><img src="/assets/projects/wortzauber-2.jpg" width="1280" height="720" alt="Berta the baker greets the hero in her bakery." loading="lazy"></figure> - <figure><img src="/assets/projects/wortzauber-3.jpg" width="1280" height="720" alt="The hero and the hovering robot at the castle scaffold with two letters collected." loading="lazy"></figure> - <figure><img src="/assets/projects/wortzauber-4.jpg" width="1280" height="720" alt="The map between the castle and the bakery village." loading="lazy"></figure> - </div> - <section class="changes" id="changes" aria-labelledby="changes-h"> <h2 id="changes-h">How it changed</h2> <p class="changes__intro">One entry per merged pull request that changed play, newest first. Each clip is filmed from the game at the state of that pull request. The clips are silent.</p>