// The Walk — the paintings are the land. One continuous world, left to right. // Arrow keys / WASD or click to walk. Nothing stops you; people talk as you pass. 'use strict'; const W = 1920, H = 1080, BASE_H = 370; const canvas = document.getElementById('c'), ctx = canvas.getContext('2d'); const params = new URLSearchParams(location.search); const SAVE_KEY = 'the-walk'; const view = { scale: 1, dpr: 1 }; const S = { mode: 'loading', world: null, images: {}, loaded: 0, x: 260, y: 900, facing: 'right', walking: false, phase: 0, sitting: false, cam: 0, target: null, keys: {}, t: 0, speech: null, queue: [], seen: {}, caption: null, segIndex: -1, ending: null, hint: 0, mouse: { x: -1, y: -1 }, }; // ---------- geometry of the ground ---------- // Each segment carries its own horizon height (hz). A person standing on ground row y is about // K * (y - horizon) pixels tall, the way perspective works from a painter's eye level. const GROUND_BOTTOM = 1056, K = 0.62, BLEND = 240; function segAt(x) { const segs = S.world.segments; for (let i = segs.length - 1; i >= 0; i--) if (x >= segs[i].x) return i; return 0; } function hzAt(x) { const segs = S.world.segments, i = segAt(x), s = segs[i]; if (i > 0 && x < s.x + BLEND) { const t = (x - s.x) / BLEND; return segs[i - 1].hz * (1 - t) + s.hz * t; } return s.hz; } function kAt(x) { const segs = S.world.segments, i = segAt(x), s = segs[i]; if (i > 0 && x < s.x + BLEND) { const t = (x - s.x) / BLEND; return (segs[i - 1].k || K) * (1 - t) + (s.k || K) * t; } return s.k || K; } function groundTop(x) { return hzAt(x) + 50; } function scaleAt(y, x = S.x) { return Math.max(0.08, Math.min(1.0, kAt(x) * (y - hzAt(x)) / BASE_H)); } // ---------- loading ---------- function loadImage(src) { return new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = () => rej(new Error(src)); im.src = src; }); } async function boot() { requestAnimationFrame(frame); S.world = await (await fetch('world.json')).json(); try { Object.assign(S.world, await (await fetch('story.json')).json()); } catch (e) { console.warn('no story.json', e); } const segs = S.world.segments; // load the first few before starting, the rest in the background const first = segs.slice(0, 3), rest = segs.slice(3); await Promise.all(first.map(async s => { S.images[s.file] = await loadImage(s.file); S.loaded++; })); S.mode = 'title'; rest.forEach(async s => { try { S.images[s.file] = await loadImage(s.file); } catch (e) { } S.loaded++; }); const save = load(); if (save && params.has('continue')) Object.assign(S, save); if (params.has('x')) { S.x = +params.get('x'); S.mode = 'play'; S.hint = -1; } S.cam = Math.max(0, Math.min(S.world.width - W, S.x - W / 2 + 160)); } function save() { try { localStorage.setItem(SAVE_KEY, JSON.stringify({ x: S.x, y: S.y, seen: S.seen })); } catch (e) { } } function load() { try { return JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); } catch (e) { return null; } } // ---------- speech ---------- // who: null = the Wanderer (follows him); or {x, y, color, name} anchored in the world. function say(text, who) { S.queue.push({ text, who }); } function updateSpeech(now) { if (S.speech && now > S.speech.until) S.speech = null; if (!S.speech && S.queue.length) { const q = S.queue.shift(); S.speech = { ...q, until: now + Math.min(6000, Math.max(1500, 48 * q.text.length)) }; } } function skipSpeech() { if (S.speech) S.speech = null; } // ---------- moments: things that happen as you pass ---------- function updateMoments() { for (const m of S.world.moments || []) { if (S.seen[m.id]) continue; if (Math.abs(S.x - m.x) > (m.r || 220)) continue; if (m.y !== undefined && Math.abs(S.y - m.y) > (m.ry || 140)) continue; S.seen[m.id] = true; for (const line of m.lines) say(line.text, line.who ? { ...(m.npc || {}), ...(line.who === true ? {} : line.who) } : null); if (m.stop) { S.target = null; } save(); } } function currentSegment() { return segAt(S.x); } // ---------- movement ---------- function updateMove(dt) { if (S.mode !== 'play' || S.ending) { S.walking = false; return; } const sc = scaleAt(S.y), speed = Math.max(220, 900 * sc), vspeed = Math.max(70, 260 * sc); let dx = 0, dy = 0; const k = S.keys; if (k.ArrowLeft || k.a) dx -= 1; if (k.ArrowRight || k.d) dx += 1; if (k.ArrowUp || k.w) dy -= 1; if (k.ArrowDown || k.s) dy += 1; if (dx || dy) { S.target = null; S.sitting = false; } if (S.target) { const tx = S.target.x - S.x, ty = S.target.y - S.y, d = Math.hypot(tx, ty); if (d < 6) S.target = null; else { dx = tx / d; dy = ty / d * (vspeed / speed) * 3.4; } } if (dx || dy) { S.x += dx * speed * dt; S.y += dy * vspeed * dt; S.x = Math.max(80, Math.min(S.world.width - 80, S.x)); S.y = Math.max(groundTop(S.x), Math.min(GROUND_BOTTOM, S.y)); S.walking = true; S.phase += dt * 11; if (Math.abs(dx) > 0.2) S.facing = dx < 0 ? 'left' : 'right'; else if (dy) S.facing = dy < 0 ? 'back' : 'front'; S.hint = -1; } else S.walking = false; // camera leads the walk a little const lead = S.facing === 'left' ? -160 : S.facing === 'right' ? 160 : 0; const want = Math.max(0, Math.min(S.world.width - W, S.x - W / 2 + lead)); S.cam += (want - S.cam) * Math.min(1, dt * 4); const si = currentSegment(); if (si !== S.segIndex) { S.segIndex = si; const s = S.world.segments[si]; S.caption = { text: `${s.artist} · ${s.title}, ${s.date}`, sub: s.credit, start: performance.now() }; } updateMoments(); if (S.x > S.world.width - 260 && !S.ending) startEnding(); } // ---------- ending ---------- function startEnding() { S.ending = { start: performance.now() }; S.walking = false; S.target = null; S.facing = 'left'; S.sitting = true; S.y = Math.max(groundTop(S.x), Math.min(S.y, 940)); for (const line of S.world.ending || []) say(line.text, line.who ? line.who : null); } // ---------- input ---------- function toLogical(ev) { const r = canvas.getBoundingClientRect(); return { x: (ev.clientX - r.left) / view.scale, y: (ev.clientY - r.top) / view.scale }; } canvas.addEventListener('pointermove', ev => { const p = toLogical(ev); S.mouse = p; }); canvas.addEventListener('pointerdown', ev => { const p = toLogical(ev); if (S.mode === 'title') { S.mode = 'play'; S.hint = 0; return; } if (S.ending) { if (performance.now() - S.ending.start > 9000) { S.ending = null; S.x = 260; S.y = 900; S.seen = {}; S.sitting = false; S.queue = []; S.speech = null; S.mode = 'title'; save(); } return; } if (S.speech && p.y < 300) { skipSpeech(); return; } S.sitting = false; S.target = { x: p.x + S.cam, y: Math.max(groundTop(p.x + S.cam), Math.min(GROUND_BOTTOM, p.y)) }; }); window.addEventListener('keydown', ev => { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(ev.key)) ev.preventDefault(); if (S.mode === 'title' && (ev.key === ' ' || ev.key === 'Enter' || ev.key.startsWith('Arrow'))) { S.mode = 'play'; S.hint = 0; } S.keys[ev.key] = true; if (ev.key === ' ') skipSpeech(); }); window.addEventListener('keyup', ev => { S.keys[ev.key] = false; }); window.addEventListener('blur', () => { S.keys = {}; }); // ---------- rendering ---------- function resize() { const s = Math.min(innerWidth / W, innerHeight / H); view.scale = s; canvas.style.width = Math.round(W * s) + 'px'; canvas.style.height = Math.round(H * s) + 'px'; view.dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(W * s * view.dpr); canvas.height = Math.round(H * s * view.dpr); } addEventListener('resize', resize); resize(); function wrapText(text, maxW) { const words = text.split(' '), lines = []; let line = ''; for (const w of words) { const t = line ? line + ' ' + w : w; if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t; } if (line) lines.push(line); return lines; } function outlined(text, x, y, color, align = 'center') { ctx.textAlign = align; ctx.lineJoin = 'round'; ctx.strokeStyle = 'rgba(0,0,0,0.9)'; ctx.lineWidth = 7; ctx.strokeText(text, x, y); ctx.fillStyle = color; ctx.fillText(text, x, y); } function drawWorld() { const segs = S.world.segments; for (const s of segs) { const sx = s.x - S.cam; if (sx > W || sx + s.w < 0) continue; const im = S.images[s.file]; if (im) ctx.drawImage(im, sx, 0, s.w, H); else { ctx.fillStyle = '#8a9aa8'; ctx.fillRect(sx, 0, s.w, H); } } // the day turns golden as the walk goes on const p = S.x / S.world.width; ctx.fillStyle = `rgba(255,170,70,${(0.16 * p * p).toFixed(3)})`; ctx.fillRect(0, 0, W, H); if (S.ending) { const el = (performance.now() - S.ending.start) / 1000, a = Math.min(0.55, el * 0.04); const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, `rgba(30,20,60,${a})`); g.addColorStop(0.5, `rgba(120,50,40,${a * 0.6})`); g.addColorStop(1, `rgba(0,0,0,${a})`); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } } function drawPlayer(t) { const sc = scaleAt(S.y); Character.draw(ctx, S.x - S.cam, S.y, { scale: sc, facing: S.facing, walking: S.walking, phase: S.phase, coat: true, stick: true, sitting: S.sitting, t }); } function drawSpeech() { const sp = S.speech; if (!sp) return; ctx.font = '600 34px Georgia, serif'; const lines = wrapText(sp.text, 800), lh = 42, th = lines.length * lh; let ax, ay, color; if (!sp.who) { const sc = scaleAt(S.y); ax = S.x - S.cam; ay = S.y - (S.sitting ? 250 : BASE_H) * sc - 20; color = '#f2ead6'; } else { ax = sp.who.x - S.cam; ay = sp.who.y; color = sp.who.color || '#ffd48a'; } const maxW = Math.max(...lines.map(l => ctx.measureText(l).width)); const x = Math.max(maxW / 2 + 24, Math.min(W - maxW / 2 - 24, ax)); const y = Math.max(70, Math.min(H - 120 - th, ay - th)); if (sp.who && sp.who.name) { ctx.font = 'italic 22px Georgia, serif'; outlined(sp.who.name, x, y - 6, '#cfc6b2'); ctx.font = '600 34px Georgia, serif'; } lines.forEach((l, i) => outlined(l, x, y + i * lh + 30, color)); } function drawCaption() { const c = S.caption; if (!c) return; const el = (performance.now() - c.start) / 1000; if (el > 5) return; const a = el < 0.4 ? el / 0.4 : el > 4 ? 5 - el : 1; ctx.save(); ctx.globalAlpha = a; ctx.font = '600 24px Georgia, serif'; outlined(c.text, 36, H - 60, '#f4ecd8', 'left'); ctx.font = '18px Georgia, serif'; outlined(c.sub, 36, H - 32, '#bfb7a4', 'left'); ctx.restore(); } function drawHud() { const segs = S.world.segments, n = segs.length, i = currentSegment(); ctx.font = '18px Georgia, serif'; outlined(`painting ${i + 1} of ${n}`, W - 36, H - 32, '#bfb7a4', 'right'); // progress line ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.fillRect(W - 336, H - 22, 300, 3); ctx.fillStyle = '#f4ecd8'; ctx.fillRect(W - 336, H - 22, 300 * S.x / S.world.width, 3); if (S.hint >= 0 && S.hint < 10) { const a = Math.min(1, S.hint) * (S.hint > 8 ? 10 - S.hint : 1); ctx.save(); ctx.globalAlpha = a; ctx.font = '600 22px Georgia, serif'; outlined('arrow keys or WASD to walk · click to walk somewhere · space skips talk', W / 2, 44, '#f0e8d4'); ctx.restore(); } } function drawTitle() { drawWorld(); ctx.fillStyle = 'rgba(0,0,0,0.42)'; ctx.fillRect(0, 0, W, H); ctx.font = '600 100px Georgia, serif'; outlined('The Walk', W / 2, 330, '#f4ecd8'); ctx.font = 'italic 32px Georgia, serif'; outlined('one continuous world, made of paintings that belong to everyone', W / 2, 390, '#d9d0bc'); if (S.mode === 'loading') { ctx.font = '22px Georgia, serif'; outlined('laying the road…', W / 2, 700, '#d9d0bc'); return; } ctx.font = '600 30px Georgia, serif'; outlined('press an arrow key, or click, to start walking', W / 2, 700, '#f4ecd8'); ctx.font = '20px Georgia, serif'; outlined(`${S.world.segments.length} paintings · ${(S.world.width / 1000).toFixed(1)} thousand steps · keep going right`, W / 2, 745, '#bfb7a4'); } function drawEnding() { const el = (performance.now() - S.ending.start) / 1000; if (el < 9) return; const a = Math.min(1, (el - 9) / 1.5); ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, W, H); ctx.font = '600 72px Georgia, serif'; outlined('The Walk', W / 2, 160, '#f4ecd8'); ctx.font = '22px Georgia, serif'; let y = 230; for (const s of S.world.segments) { outlined(`${s.artist} — ${s.title}, ${s.date} · ${s.credit}`, W / 2, y, '#d9d0bc'); y += 34; } ctx.font = 'italic 24px Georgia, serif'; outlined('Every one of these belongs to everyone. That is what public domain means.', W / 2, y + 30, '#f4ecd8'); ctx.font = '20px Georgia, serif'; outlined('click to walk again', W / 2, H - 50, '#bfb7a4'); ctx.restore(); } let last = performance.now(); function frame(now) { const dt = Math.min(0.05, (now - last) / 1000); last = now; S.t += dt; ctx.setTransform(view.scale * view.dpr, 0, 0, view.scale * view.dpr, 0, 0); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); if (!S.world) { requestAnimationFrame(frame); return; } if (S.mode === 'loading' || S.mode === 'title') { drawTitle(); requestAnimationFrame(frame); return; } if (S.hint >= 0) S.hint += dt; updateMove(dt); updateSpeech(now); drawWorld(); drawPlayer(S.t); drawSpeech(); drawCaption(); drawHud(); if (S.ending) drawEnding(); requestAnimationFrame(frame); } boot();